Compare commits
No commits in common. "f69a52779f9cdbe66d09749b033393ed8cfb652e" and "4215001ab78ebcb5f3844f4bda36364951d18be4" have entirely different histories.
f69a52779f
...
4215001ab7
|
|
@ -8,9 +8,6 @@
|
||||||
"name": "construction-delivery",
|
"name": "construction-delivery",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@supabase/supabase-js": "2.52.0",
|
"@supabase/supabase-js": "2.52.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|
@ -342,59 +339,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/core": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0",
|
|
||||||
"react-dom": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/sortable": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@dnd-kit/core": "^6.3.0",
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/utilities": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@
|
||||||
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@supabase/supabase-js": "2.52.0",
|
"@supabase/supabase-js": "2.52.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|
@ -23,8 +20,8 @@
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.3.0",
|
"react-router-dom": "7.3.0",
|
||||||
"recharts": "^2.15.0",
|
"tailwind-merge": "3.3.0",
|
||||||
"tailwind-merge": "3.3.0"
|
"recharts": "^2.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v11";
|
const STATIC_CACHE = "construction-delivery-static-v10";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v11";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v10";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,9 @@ SuperSam — SMS First Campaign (State Machine)
|
||||||
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||||||
3. Обновляет статусы в order_groups + sms_campaign_log
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
|
|
||||||
Защита от повторной отправки (ДВОЙНАЯ):
|
Защита от повторной отправки:
|
||||||
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
- Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes → skip
|
||||||
→ get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
|
- Код 231/132 = лимит одинаковых → не повторять
|
||||||
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
|
||||||
3. Код 231/132 = лимит одинаковых → не повторять
|
|
||||||
|
|
||||||
needs_check=true — admin нажал "Проверить снова" в UI
|
|
||||||
→ скрипт проверяет даже если запись старше max_check_duration
|
|
||||||
|
|
||||||
Коды sms.ru:
|
Коды sms.ru:
|
||||||
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||||
|
|
@ -36,7 +31,7 @@ from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.5")
|
||||||
DB_PORT = os.environ.get("DB_PORT", "5432")
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
|
@ -197,26 +192,19 @@ def get_groups_to_send(conn):
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены"""
|
||||||
Включает:
|
|
||||||
- Записи младше max_duration_min (обычная автопроверка)
|
|
||||||
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
|
||||||
"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
scl.attempts, scl.created_at, scl.sms_code,
|
||||||
og.customer_name, og.group_key
|
og.customer_name, og.group_key
|
||||||
FROM sms_campaign_log scl
|
FROM sms_campaign_log scl
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
WHERE scl.campaign_type = 'first_sms'
|
WHERE scl.campaign_type = 'first_sms'
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND scl.status IN ('sent', 'checking')
|
||||||
AND scl.sms_id IS NOT NULL
|
AND scl.sms_id IS NOT NULL
|
||||||
AND (
|
AND scl.created_at > NOW() - INTERVAL '%s minutes'
|
||||||
scl.created_at > NOW() - INTERVAL '%s minutes'
|
ORDER BY scl.created_at ASC
|
||||||
OR scl.needs_check = true
|
|
||||||
)
|
|
||||||
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
""" % max_duration_min)
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
@ -251,9 +239,6 @@ def update_sms_log(conn, log_id, **kwargs):
|
||||||
set_parts = []
|
set_parts = []
|
||||||
values = []
|
values = []
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
set_parts.append(f"{k} = %s")
|
||||||
values.append(v)
|
values.append(v)
|
||||||
values.append(log_id)
|
values.append(log_id)
|
||||||
|
|
@ -328,13 +313,7 @@ def step_send_new(conn, settings):
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=1,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}")
|
||||||
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "sms_sending",
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
else:
|
else:
|
||||||
# Ошибка отправки
|
# Ошибка отправки
|
||||||
|
|
@ -378,13 +357,9 @@ def step_check_status(conn, settings):
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts", 1)
|
||||||
was_needs_check = item.get("needs_check", False)
|
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
||||||
# Сбрасываем needs_check + ставим checked_at
|
|
||||||
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
|
||||||
|
|
||||||
if code == DELIVERED_CODE:
|
if code == DELIVERED_CODE:
|
||||||
# Доставлено!
|
# Доставлено!
|
||||||
log.info(f"Group {group_id}: SMS delivered (103)!")
|
log.info(f"Group {group_id}: SMS delivered (103)!")
|
||||||
|
|
@ -416,11 +391,10 @@ def step_check_status(conn, settings):
|
||||||
# Переход к следующей попытке отправки (если есть)
|
# Переход к следующей попытке отправки (если есть)
|
||||||
if attempts < max_attempts:
|
if attempts < max_attempts:
|
||||||
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
||||||
|
# Сбрасываем, чтобы step_send_new подхватил (но только если нет другой недавней)
|
||||||
|
# Удаляем лог, чтобы группа снова попала в get_groups_to_send
|
||||||
|
# Нет — лучше помечаем как expired, а step_send_new проверяет отсутствие активных
|
||||||
update_sms_log(conn, log_id, status="expired")
|
update_sms_log(conn, log_id, status="expired")
|
||||||
# Сбрасываем notification_status → link_ready для повторной отправки
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "link_ready",
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "send_failed",
|
||||||
|
|
@ -469,11 +443,8 @@ def step_handle_expired(conn, settings):
|
||||||
error_message=f"Not delivered in {max_duration} minutes")
|
error_message=f"Not delivered in {max_duration} minutes")
|
||||||
|
|
||||||
if attempts < max_attempts:
|
if attempts < max_attempts:
|
||||||
# Сбрасываем notification_status → link_ready для повторной отправки
|
# Будет повторная отправка на следующем запуске (step_send_new подхватит)
|
||||||
update_order_group(conn, group_id, {
|
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1})")
|
||||||
"notification_status": "link_ready",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
|
|
||||||
else:
|
else:
|
||||||
# Все попытки исчерпаны
|
# Все попытки исчерпаны
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
|
|
|
||||||
|
|
@ -1,583 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
SuperSam — SMS First Campaign (State Machine)
|
|
||||||
Заменяет n8n workflow "Первая отправка согласования"
|
|
||||||
|
|
||||||
Архитектура: state machine через cron (каждые 5 мин)
|
|
||||||
Каждый запуск:
|
|
||||||
1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
|
|
||||||
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
|
||||||
3. Обновляет статусы в order_groups + sms_campaign_log
|
|
||||||
|
|
||||||
Защита от повторной отправки (ДВОЙНАЯ):
|
|
||||||
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
|
||||||
→ get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
|
|
||||||
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
|
||||||
3. Код 231/132 = лимит одинаковых → не повторять
|
|
||||||
|
|
||||||
needs_check=true — admin нажал "Проверить снова" в UI
|
|
||||||
→ скрипт проверяет даже если запись старше max_check_duration
|
|
||||||
|
|
||||||
Коды sms.ru:
|
|
||||||
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
|
||||||
103 = доставлено → цель
|
|
||||||
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
|
||||||
DB_PORT = os.environ.get("DB_PORT", "5432")
|
|
||||||
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
|
||||||
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
|
||||||
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
||||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
|
||||||
|
|
||||||
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
|
||||||
SMS_SEND_URL = "https://sms.ru/sms/send"
|
|
||||||
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
|
||||||
|
|
||||||
LOG_FILE = "/var/log/supersam-sms-first.log"
|
|
||||||
|
|
||||||
# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
|
|
||||||
# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
|
|
||||||
# Мегаадмин выключает через админку → SMS идут реальным клиентам
|
|
||||||
|
|
||||||
# Коды, которые означают "в процессе" (ждём дальше)
|
|
||||||
IN_TRANSIT_CODES = {"100", "101", "102"}
|
|
||||||
# Код доставки
|
|
||||||
DELIVERED_CODE = "103"
|
|
||||||
# Коды ошибок доставки (не временные, можно повторять отправку)
|
|
||||||
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
|
||||||
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
|
||||||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
|
||||||
|
|
||||||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(LOG_FILE),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
log = logging.getLogger("sms_first")
|
|
||||||
|
|
||||||
# ─── БД ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_db_conn():
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
|
||||||
user=DB_USER, password=DB_PASS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_settings(conn):
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"wait_between_checks_seconds": 25,
|
|
||||||
"max_check_duration_minutes": 90,
|
|
||||||
"max_attempts": 2,
|
|
||||||
"enabled": True,
|
|
||||||
"telegram_chat_id": "25164483",
|
|
||||||
"sms_api_id": SMS_API_ID,
|
|
||||||
"test_mode": True,
|
|
||||||
"test_phone": "79788382260",
|
|
||||||
}
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def normalize_phone(phone):
|
|
||||||
"""Нормализует телефон: только цифры, начинается с 7"""
|
|
||||||
clean = "".join(c for c in str(phone) if c.isdigit())
|
|
||||||
if clean.startswith("8"):
|
|
||||||
clean = "7" + clean[1:]
|
|
||||||
elif not clean.startswith("7"):
|
|
||||||
clean = "7" + clean
|
|
||||||
return clean
|
|
||||||
|
|
||||||
def send_sms(phone, message, api_id):
|
|
||||||
"""Отправляет SMS, возвращает (sms_id, raw_response)"""
|
|
||||||
try:
|
|
||||||
clean_phone = normalize_phone(phone)
|
|
||||||
resp = requests.post(SMS_SEND_URL, params={
|
|
||||||
"api_id": api_id,
|
|
||||||
"to": clean_phone,
|
|
||||||
}, data={"msg": message}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
|
||||||
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
status_code = lines[0].strip() if lines else ""
|
|
||||||
|
|
||||||
if status_code == "100" and len(lines) >= 2:
|
|
||||||
sms_id = lines[1].strip()
|
|
||||||
return sms_id, text, "100"
|
|
||||||
else:
|
|
||||||
return None, text, status_code
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS send error: {e}")
|
|
||||||
return None, str(e), "error"
|
|
||||||
|
|
||||||
def check_sms_status(sms_id, api_id):
|
|
||||||
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
|
||||||
try:
|
|
||||||
resp = requests.post(SMS_STATUS_URL, params={
|
|
||||||
"api_id": api_id,
|
|
||||||
"sms_id": sms_id,
|
|
||||||
}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
status_code = lines[0].strip() if lines else ""
|
|
||||||
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
|
||||||
|
|
||||||
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
|
||||||
return sms_status_code, text, status_code
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS status check error: {e}")
|
|
||||||
return None, str(e), "error"
|
|
||||||
|
|
||||||
def fetch_balance(api_id):
|
|
||||||
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
|
|
||||||
try:
|
|
||||||
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
|
||||||
text = resp.text
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
if lines[0].strip() == "100" and len(lines) >= 2:
|
|
||||||
return float(lines[1].strip()), text
|
|
||||||
return None, text
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Balance fetch error: {e}")
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_telegram(message, chat_id):
|
|
||||||
if not TELEGRAM_BOT_TOKEN:
|
|
||||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
||||||
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
log.warning(f"Telegram error: {resp.text[:200]}")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"Telegram failed: {e}")
|
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
|
||||||
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
|
||||||
FROM order_groups og
|
|
||||||
WHERE og.status = 'ready_to_launch'
|
|
||||||
AND og.delivery_status = 'pending_confirmation'
|
|
||||||
AND og.delivery_link IS NOT NULL
|
|
||||||
AND og.delivery_link != ''
|
|
||||||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
|
||||||
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
|
||||||
WHERE scl.order_group_id = og.id
|
|
||||||
AND scl.campaign_type = 'first_sms'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
|
||||||
)
|
|
||||||
ORDER BY og.created_at ASC
|
|
||||||
""")
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
|
||||||
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
|
||||||
Включает:
|
|
||||||
- Записи младше max_duration_min (обычная автопроверка)
|
|
||||||
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
|
||||||
"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
|
||||||
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
|
||||||
og.customer_name, og.group_key
|
|
||||||
FROM sms_campaign_log scl
|
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
|
||||||
WHERE scl.campaign_type = 'first_sms'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.sms_id IS NOT NULL
|
|
||||||
AND (
|
|
||||||
scl.created_at > NOW() - INTERVAL '%s minutes'
|
|
||||||
OR scl.needs_check = true
|
|
||||||
)
|
|
||||||
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_sms_expired(conn, max_duration_min):
|
|
||||||
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
|
||||||
scl.attempts, scl.created_at,
|
|
||||||
og.customer_name, og.group_key, og.delivery_link
|
|
||||||
FROM sms_campaign_log scl
|
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
|
||||||
WHERE scl.campaign_type = 'first_sms'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.sms_id IS NOT NULL
|
|
||||||
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
|
||||||
ORDER BY scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def insert_sms_log(conn, **kwargs):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cols = ", ".join(kwargs.keys())
|
|
||||||
placeholders = ", ".join(["%s"] * len(kwargs))
|
|
||||||
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
|
||||||
log_id = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
return log_id
|
|
||||||
|
|
||||||
def update_sms_log(conn, log_id, **kwargs):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(log_id)
|
|
||||||
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def update_order_group(conn, group_id, fields):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in fields.items():
|
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(group_id)
|
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
|
||||||
"""Проверка: сейчас рабочие часы.
|
|
||||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
|
||||||
"""
|
|
||||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
|
||||||
today_num = now_msk.weekday() + 1
|
|
||||||
allowed_days = set()
|
|
||||||
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
|
||||||
for part in str(work_days_str).split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if part.isdigit():
|
|
||||||
allowed_days.add(int(part))
|
|
||||||
if today_num not in allowed_days:
|
|
||||||
return False
|
|
||||||
hour = now_msk.hour
|
|
||||||
start_h = settings.get("work_hours_start", 8)
|
|
||||||
end_h = settings.get("work_hours_end", 21)
|
|
||||||
return start_h <= hour < end_h
|
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def step_send_new(conn, settings):
|
|
||||||
"""Шаг 1: Отправка SMS новым группам"""
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
|
||||||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
|
||||||
|
|
||||||
groups = get_groups_to_send(conn)
|
|
||||||
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
|
||||||
|
|
||||||
sent_count = 0
|
|
||||||
for group in groups:
|
|
||||||
group_id = str(group["id"])
|
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
|
||||||
delivery_link = group.get("delivery_link", "")
|
|
||||||
|
|
||||||
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
|
||||||
WHERE order_group_id = %s AND campaign_type = 'first_sms'
|
|
||||||
AND created_at > NOW() - INTERVAL '24 hours'
|
|
||||||
AND status IN ('sent', 'checking', 'delivered')
|
|
||||||
""", (group_id,))
|
|
||||||
recent_count = cur.fetchone()[0]
|
|
||||||
if recent_count > 0:
|
|
||||||
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
|
||||||
|
|
||||||
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
|
||||||
send_phone = phone
|
|
||||||
if settings.get("test_mode", True):
|
|
||||||
send_phone = settings.get("test_phone", "79788382260")
|
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
|
||||||
|
|
||||||
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
|
|
||||||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
|
||||||
|
|
||||||
if sms_id:
|
|
||||||
log_id = insert_sms_log(conn,
|
|
||||||
campaign_type="first_sms",
|
|
||||||
order_group_id=group_id,
|
|
||||||
customer_phone=phone,
|
|
||||||
sms_id=sms_id,
|
|
||||||
sms_text=sms_text,
|
|
||||||
status="sent",
|
|
||||||
sms_code=code,
|
|
||||||
attempts=1,
|
|
||||||
)
|
|
||||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
|
||||||
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "sms_sending",
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
|
||||||
sent_count += 1
|
|
||||||
else:
|
|
||||||
# Ошибка отправки
|
|
||||||
error = raw[:500] if raw else "Unknown error"
|
|
||||||
log_id = insert_sms_log(conn,
|
|
||||||
campaign_type="first_sms",
|
|
||||||
order_group_id=group_id,
|
|
||||||
customer_phone=phone,
|
|
||||||
sms_text=sms_text,
|
|
||||||
status="send_failed",
|
|
||||||
sms_code=code,
|
|
||||||
attempts=1,
|
|
||||||
error_message=error,
|
|
||||||
)
|
|
||||||
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
|
||||||
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "send_failed",
|
|
||||||
"last_sms_error": error[:200],
|
|
||||||
})
|
|
||||||
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
|
||||||
|
|
||||||
return sent_count
|
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
|
||||||
"""Шаг 2: Проверка статуса ранее отправленных SMS"""
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
max_duration = settings.get("max_check_duration_minutes", 90)
|
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
|
||||||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
|
||||||
|
|
||||||
sms_list = get_sms_to_check(conn, max_duration)
|
|
||||||
log.info(f"Step 2: {len(sms_list)} SMS to check status")
|
|
||||||
|
|
||||||
delivered = 0
|
|
||||||
for item in sms_list:
|
|
||||||
log_id = item["log_id"]
|
|
||||||
sms_id = item["sms_id"]
|
|
||||||
group_id = str(item["order_group_id"])
|
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
|
||||||
phone = item.get("customer_phone", "")
|
|
||||||
attempts = item.get("attempts", 1)
|
|
||||||
was_needs_check = item.get("needs_check", False)
|
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
|
||||||
|
|
||||||
# Сбрасываем needs_check + ставим checked_at
|
|
||||||
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
|
||||||
|
|
||||||
if code == DELIVERED_CODE:
|
|
||||||
# Доставлено!
|
|
||||||
log.info(f"Group {group_id}: SMS delivered (103)!")
|
|
||||||
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
|
||||||
|
|
||||||
next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat()
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "first_sms_sent",
|
|
||||||
"sms_attempts": attempts,
|
|
||||||
"first_sms_sent_at": "NOW()",
|
|
||||||
"last_sms_error": None,
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
"next_notification_check_at": next_check,
|
|
||||||
"status": "first_sms_sent",
|
|
||||||
})
|
|
||||||
send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
|
|
||||||
delivered += 1
|
|
||||||
|
|
||||||
elif code in IN_TRANSIT_CODES:
|
|
||||||
# В пути / в очереди — продолжаем ждать
|
|
||||||
log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run")
|
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
|
||||||
|
|
||||||
elif code in DELIVERY_ERROR_CODES:
|
|
||||||
# Ошибка доставки — можно повторить отправку
|
|
||||||
log.error(f"Group {group_id}: SMS delivery error (code={code})")
|
|
||||||
update_sms_log(conn, log_id, status="error", sms_code=code,
|
|
||||||
error_message=f"Delivery error: {code}")
|
|
||||||
# Переход к следующей попытке отправки (если есть)
|
|
||||||
if attempts < max_attempts:
|
|
||||||
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
|
||||||
update_sms_log(conn, log_id, status="expired")
|
|
||||||
# Сбрасываем notification_status → link_ready для повторной отправки
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "link_ready",
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "send_failed",
|
|
||||||
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
|
||||||
})
|
|
||||||
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
|
||||||
|
|
||||||
elif code in LIMIT_ERROR_CODES:
|
|
||||||
# Превышен лимит — НЕ повторять отправку!
|
|
||||||
log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying")
|
|
||||||
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
|
|
||||||
error_message=f"Limit exceeded: code={code}")
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "send_failed",
|
|
||||||
"last_sms_error": f"Limit exceeded (code={code}), sending blocked",
|
|
||||||
})
|
|
||||||
send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Неизвестный код — логируем, продолжаем проверять
|
|
||||||
log.warning(f"Group {group_id}: unknown SMS code: {code}")
|
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code,
|
|
||||||
error_message=f"Unknown code: {code}")
|
|
||||||
|
|
||||||
return delivered
|
|
||||||
|
|
||||||
def step_handle_expired(conn, settings):
|
|
||||||
"""Шаг 3: Обработка SMS с истёкшим сроком проверки"""
|
|
||||||
max_duration = settings.get("max_check_duration_minutes", 90)
|
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
|
|
||||||
expired = get_sms_expired(conn, max_duration)
|
|
||||||
log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
|
|
||||||
|
|
||||||
for item in expired:
|
|
||||||
log_id = item["log_id"]
|
|
||||||
group_id = str(item["order_group_id"])
|
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
|
||||||
phone = item.get("customer_phone", "")
|
|
||||||
attempts = item.get("attempts", 1)
|
|
||||||
delivery_link = item.get("delivery_link", "")
|
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
|
||||||
update_sms_log(conn, log_id, status="expired",
|
|
||||||
error_message=f"Not delivered in {max_duration} minutes")
|
|
||||||
|
|
||||||
if attempts < max_attempts:
|
|
||||||
# Сбрасываем notification_status → link_ready для повторной отправки
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "link_ready",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
|
|
||||||
else:
|
|
||||||
# Все попытки исчерпаны
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "manual_required",
|
|
||||||
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
|
||||||
})
|
|
||||||
send_telegram(
|
|
||||||
f"🔧 Требуется ручное управление: {name} ({phone})\n"
|
|
||||||
f"SMS не доставлена после {max_attempts} попыток",
|
|
||||||
tg_chat,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
log.info("=" * 60)
|
|
||||||
log.info("SMS First Campaign — START")
|
|
||||||
conn = get_db_conn()
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = load_settings(conn)
|
|
||||||
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
|
||||||
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
|
||||||
f"max_attempts={settings.get('max_attempts')}")
|
|
||||||
|
|
||||||
if not settings.get("enabled", True):
|
|
||||||
log.info("Campaign disabled, exiting")
|
|
||||||
return
|
|
||||||
|
|
||||||
# State machine — каждый шаг быстрый, без blocking
|
|
||||||
# Отправка только в рабочие часы
|
|
||||||
work_hours = is_within_work_hours(settings)
|
|
||||||
sent = 0
|
|
||||||
if work_hours:
|
|
||||||
sent = step_send_new(conn, settings)
|
|
||||||
else:
|
|
||||||
log.info("Outside work hours, skipping new SMS sends")
|
|
||||||
|
|
||||||
# Проверка статусов работает всегда
|
|
||||||
delivered = step_check_status(conn, settings)
|
|
||||||
step_handle_expired(conn, settings)
|
|
||||||
|
|
||||||
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
|
||||||
|
|
||||||
if sent > 0 or delivered > 0:
|
|
||||||
send_telegram(
|
|
||||||
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
|
||||||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем баланс sms.ru
|
|
||||||
if sent > 0:
|
|
||||||
balance, raw = fetch_balance(api_id)
|
|
||||||
if balance is not None:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
|
|
||||||
(balance,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
log.info(f"Balance updated: {balance} ₽")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Fatal error: {e}", exc_info=True)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log.info("SMS First Campaign — END")
|
|
||||||
log.info("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
SuperSam — Manual Campaign (State Machine)
|
|
||||||
Заменяет n8n workflow "Ручное управление"
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
Если после второй SMS (notification_status = 'second_sms_sent')
|
|
||||||
прошло auto_manual_after_hours и клиент не согласовал
|
|
||||||
(delivery_status = 'pending_confirmation'):
|
|
||||||
→ notification_status = 'manual_required'
|
|
||||||
→ delivery_status = 'manual_confirmation_required'
|
|
||||||
→ next_notification_check_at = +3 мин
|
|
||||||
→ Telegram-уведомление
|
|
||||||
|
|
||||||
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
|
||||||
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import fcntl
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
|
||||||
DB_PORT = os.environ.get("DB_PORT", "5432")
|
|
||||||
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
|
||||||
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
|
||||||
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
||||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
|
||||||
|
|
||||||
LOG_FILE = "/var/log/supersam-sms-manual.log"
|
|
||||||
|
|
||||||
CAMPAIGN_TYPE = "manual"
|
|
||||||
|
|
||||||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(LOG_FILE),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
log = logging.getLogger("sms_manual")
|
|
||||||
|
|
||||||
# ─── БД ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_db_conn():
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
|
||||||
user=DB_USER, password=DB_PASS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_settings(conn):
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_telegram(message, chat_id):
|
|
||||||
pass # Telegram notifications moved to n8n+Supabase integration
|
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
|
||||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
|
||||||
today_num = now_msk.weekday() + 1
|
|
||||||
allowed_days = set()
|
|
||||||
work_days_str = settings.get("work_days", "1,2,3,4,5,6")
|
|
||||||
for part in str(work_days_str).split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if part.isdigit():
|
|
||||||
allowed_days.add(int(part))
|
|
||||||
if today_num not in allowed_days:
|
|
||||||
return False
|
|
||||||
hour = now_msk.hour
|
|
||||||
start_h = settings.get("work_hours_start", 9)
|
|
||||||
end_h = settings.get("work_hours_end", 21)
|
|
||||||
return start_h <= hour < end_h
|
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_groups_to_manual(conn):
|
|
||||||
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
|
||||||
|
|
||||||
Сценарии:
|
|
||||||
1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS
|
|
||||||
2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS
|
|
||||||
3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа
|
|
||||||
4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа
|
|
||||||
"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
|
||||||
og.notification_status, og.delivery_link,
|
|
||||||
og.first_sms_sent_at, og.second_sms_sent_at,
|
|
||||||
og.sms_sent_at, og.next_notification_check_at,
|
|
||||||
og.sms_attempts
|
|
||||||
FROM order_groups og
|
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
|
||||||
AND (
|
|
||||||
-- 1. second_sms_sent + срок истёк
|
|
||||||
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
|
|
||||||
-- 2. first_sms_sent + срок истёк (>3h после отправки)
|
|
||||||
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
|
|
||||||
-- Иначе second_campaign не успеет отправить вторую SMS
|
|
||||||
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
|
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
|
||||||
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
|
|
||||||
AND og.second_sms_sent_at IS NOT NULL)
|
|
||||||
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
|
||||||
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
|
||||||
WHERE scl.order_group_id = og.id
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
|
||||||
))
|
|
||||||
-- 4. sms_sending/checking + >3h без доставки (зависло)
|
|
||||||
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
|
|
||||||
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
|
|
||||||
)
|
|
||||||
ORDER BY og.created_at ASC
|
|
||||||
""")
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def update_order_group(conn, group_id, fields):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in fields.items():
|
|
||||||
if isinstance(v, str) and v.startswith("NOW()"):
|
|
||||||
set_parts.append(f"{k} = {v}")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(group_id)
|
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def step_move_to_manual(conn, settings):
|
|
||||||
"""Перевод групп в ручное управление"""
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
delay_minutes = settings.get("auto_manual_after_hours", 3)
|
|
||||||
|
|
||||||
groups = get_groups_to_manual(conn)
|
|
||||||
log.info(f"Step 1: {len(groups)} groups to move to manual")
|
|
||||||
|
|
||||||
moved = 0
|
|
||||||
for group in groups:
|
|
||||||
group_id = str(group["id"])
|
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
|
||||||
phone = group.get("customer_phone", "")
|
|
||||||
delivery_link = group.get("delivery_link", "")
|
|
||||||
|
|
||||||
log.info(f"Group {group_id}: moving to manual_required ({name})")
|
|
||||||
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "manual_required",
|
|
||||||
"delivery_status": "manual_confirmation_required",
|
|
||||||
"sms_attempts": 2,
|
|
||||||
"last_sms_error": None,
|
|
||||||
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
|
||||||
"status": "manual_required",
|
|
||||||
})
|
|
||||||
|
|
||||||
send_telegram(
|
|
||||||
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
|
||||||
f"Клиент не согласовал доставку после двух SMS\n"
|
|
||||||
f"Ссылка: {delivery_link}",
|
|
||||||
tg_chat,
|
|
||||||
)
|
|
||||||
moved += 1
|
|
||||||
|
|
||||||
return moved
|
|
||||||
|
|
||||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# File lock — prevent parallel execution
|
|
||||||
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except (IOError, OSError):
|
|
||||||
log.info("Another instance is running, exiting")
|
|
||||||
lock_file.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
log.info("=" * 60)
|
|
||||||
log.info("Manual Campaign — START")
|
|
||||||
conn = get_db_conn()
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = load_settings(conn)
|
|
||||||
|
|
||||||
# Update last_run_at timestamp
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Check test_send_requested — one-time test send
|
|
||||||
test_send_requested = bool(settings.get("test_send_requested", False))
|
|
||||||
if test_send_requested:
|
|
||||||
# Reset flag immediately
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
|
||||||
conn.commit()
|
|
||||||
log.info("Test send requested — will send ONE SMS to test phone only")
|
|
||||||
log.info(f"Settings: work_hours={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
|
||||||
f"work_days={settings.get('work_days')}")
|
|
||||||
|
|
||||||
if not settings.get("enabled", True):
|
|
||||||
log.info("Campaign disabled, exiting")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Только в рабочие часы
|
|
||||||
if not is_within_work_hours(settings):
|
|
||||||
log.info("Outside work hours, skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
if test_send_requested:
|
|
||||||
log.info("Test send requested for manual campaign — no SMS to send, marking as done")
|
|
||||||
|
|
||||||
# run_requested for manual = just run the step
|
|
||||||
run_requested = bool(settings.get("run_requested", False))
|
|
||||||
if run_requested:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
|
||||||
conn.commit()
|
|
||||||
log.info("RESTART: manual campaign — will process all eligible groups now")
|
|
||||||
else:
|
|
||||||
moved = step_move_to_manual(conn, settings)
|
|
||||||
|
|
||||||
log.info(f"Run summary: moved_to_manual={moved}")
|
|
||||||
|
|
||||||
if moved > 0:
|
|
||||||
send_telegram(
|
|
||||||
f"📊 <b>Ручное управление</b>\nПереведено в ручное: {moved}",
|
|
||||||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Fatal error: {e}", exc_info=True)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
||||||
lock_file.close()
|
|
||||||
|
|
||||||
log.info("Manual Campaign — END")
|
|
||||||
log.info("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
SuperSam — Paid Storage Campaign (State Machine)
|
|
||||||
Уведомление о включении платного хранения.
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
Когда delivery_status = 'paid_storage' (логист/админ включил):
|
|
||||||
→ Если рабочие часы (8-21, Пн-Пт): отправить SMS один раз
|
|
||||||
→ Если ночью: ждать до утра
|
|
||||||
→ Проверить доставку (103)
|
|
||||||
→ Пометить notification_status = 'paid_storage_sent'
|
|
||||||
|
|
||||||
SMS текст: "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день.
|
|
||||||
Заберите заказ или согласуйте доставку: {link}"
|
|
||||||
|
|
||||||
Защита от повторной отправки:
|
|
||||||
1. После отправки → notification_status = 'paid_storage_sending'
|
|
||||||
2. EXISTS проверка в sms_campaign_log за 24h
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
|
||||||
DB_PORT = os.environ.get("DB_PORT", "5432")
|
|
||||||
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
|
||||||
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
|
||||||
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
||||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
|
||||||
|
|
||||||
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
|
||||||
SMS_SEND_URL = "https://sms.ru/sms/send"
|
|
||||||
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
|
||||||
|
|
||||||
LOG_FILE = "/var/log/supersam-sms-paid-storage.log"
|
|
||||||
|
|
||||||
CAMPAIGN_TYPE = "paid_storage"
|
|
||||||
|
|
||||||
IN_TRANSIT_CODES = {"100", "101", "102"}
|
|
||||||
DELIVERED_CODE = "103"
|
|
||||||
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
|
||||||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
|
||||||
|
|
||||||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(LOG_FILE),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
log = logging.getLogger("sms_paid_storage")
|
|
||||||
|
|
||||||
# ─── БД ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_db_conn():
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
|
||||||
user=DB_USER, password=DB_PASS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_settings(conn):
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"enabled": True, "test_mode": True, "test_phone": "79788382260",
|
|
||||||
"max_check_duration_minutes": 90, "max_attempts": 2,
|
|
||||||
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
|
|
||||||
"work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
|
|
||||||
}
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def normalize_phone(phone):
|
|
||||||
clean = "".join(c for c in str(phone) if c.isdigit())
|
|
||||||
if clean.startswith("8"):
|
|
||||||
clean = "7" + clean[1:]
|
|
||||||
elif not clean.startswith("7"):
|
|
||||||
clean = "7" + clean
|
|
||||||
return clean
|
|
||||||
|
|
||||||
def send_sms(phone, message, api_id):
|
|
||||||
try:
|
|
||||||
clean_phone = normalize_phone(phone)
|
|
||||||
resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
|
|
||||||
data={"msg": message}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
status_code = lines[0].strip() if lines else ""
|
|
||||||
if status_code == "100" and len(lines) >= 2:
|
|
||||||
return lines[1].strip(), text, "100"
|
|
||||||
return None, text, status_code
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS send error: {e}")
|
|
||||||
return None, str(e), "error"
|
|
||||||
|
|
||||||
def check_sms_status(sms_id, api_id):
|
|
||||||
try:
|
|
||||||
resp = requests.post(SMS_STATUS_URL, params={"api_id": api_id, "sms_id": sms_id}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
status_code = lines[0].strip() if lines else ""
|
|
||||||
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
|
||||||
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
|
||||||
return sms_status_code, text, status_code
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS status check error: {e}")
|
|
||||||
return None, str(e), "error"
|
|
||||||
|
|
||||||
def fetch_balance(api_id):
|
|
||||||
try:
|
|
||||||
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
|
||||||
text = resp.text
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
if lines[0].strip() == "100" and len(lines) >= 2:
|
|
||||||
return float(lines[1].strip()), text
|
|
||||||
return None, text
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Balance fetch error: {e}")
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_telegram(message, chat_id):
|
|
||||||
if not TELEGRAM_BOT_TOKEN:
|
|
||||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
||||||
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
log.warning(f"Telegram error: {resp.text[:200]}")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"Telegram failed: {e}")
|
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
|
||||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
|
||||||
today_num = now_msk.weekday() + 1
|
|
||||||
allowed_days = set()
|
|
||||||
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
|
||||||
for part in str(work_days_str).split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if part.isdigit():
|
|
||||||
allowed_days.add(int(part))
|
|
||||||
if today_num not in allowed_days:
|
|
||||||
return False
|
|
||||||
hour = now_msk.hour
|
|
||||||
start_h = settings.get("work_hours_start", 8)
|
|
||||||
end_h = settings.get("work_hours_end", 21)
|
|
||||||
return start_h <= hour < end_h
|
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
|
||||||
"""Группы с paid_storage, которым ещё не отправили уведомление."""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
|
||||||
og.paid_storage_at
|
|
||||||
FROM order_groups og
|
|
||||||
WHERE og.delivery_status = 'paid_storage'
|
|
||||||
AND og.paid_storage_at IS NOT NULL
|
|
||||||
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
|
||||||
AND og.delivery_link IS NOT NULL
|
|
||||||
AND og.delivery_link != ''
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
|
||||||
WHERE scl.order_group_id = og.id
|
|
||||||
AND scl.campaign_type = 'paid_storage'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.created_at > NOW() - INTERVAL '24 hours'
|
|
||||||
)
|
|
||||||
ORDER BY og.paid_storage_at ASC
|
|
||||||
""")
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
|
||||||
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
|
||||||
og.customer_name, og.group_key
|
|
||||||
FROM sms_campaign_log scl
|
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
|
||||||
WHERE scl.campaign_type = 'paid_storage'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.sms_id IS NOT NULL
|
|
||||||
AND (scl.created_at > NOW() - INTERVAL '%s minutes' OR scl.needs_check = true)
|
|
||||||
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_sms_expired(conn, max_duration_min):
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
|
||||||
scl.attempts, scl.created_at,
|
|
||||||
og.customer_name, og.group_key, og.delivery_link
|
|
||||||
FROM sms_campaign_log scl
|
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
|
||||||
WHERE scl.campaign_type = 'paid_storage'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.sms_id IS NOT NULL
|
|
||||||
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
|
||||||
ORDER BY scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def insert_sms_log(conn, **kwargs):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cols = ", ".join(kwargs.keys())
|
|
||||||
placeholders = ", ".join(["%s"] * len(kwargs))
|
|
||||||
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
|
||||||
log_id = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
return log_id
|
|
||||||
|
|
||||||
def update_sms_log(conn, log_id, **kwargs):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(log_id)
|
|
||||||
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def update_order_group(conn, group_id, fields):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in fields.items():
|
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(group_id)
|
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def step_send_new(conn, settings):
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
|
|
||||||
groups = get_groups_to_send(conn)
|
|
||||||
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
|
||||||
|
|
||||||
sent_count = 0
|
|
||||||
for group in groups:
|
|
||||||
group_id = str(group["id"])
|
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
|
||||||
delivery_link = group.get("delivery_link", "")
|
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) FROM sms_campaign_log
|
|
||||||
WHERE order_group_id = %s AND campaign_type = 'paid_storage'
|
|
||||||
AND created_at > NOW() - INTERVAL '24 hours'
|
|
||||||
AND status IN ('sent', 'checking', 'delivered')
|
|
||||||
""", (group_id,))
|
|
||||||
if cur.fetchone()[0] > 0:
|
|
||||||
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
sms_text = (
|
|
||||||
f"Ваш заказ переведён на платное хранение. "
|
|
||||||
f"Стоимость: 300 ₽/день. "
|
|
||||||
f"Заберите заказ или согласуйте доставку: {delivery_link}"
|
|
||||||
)
|
|
||||||
|
|
||||||
send_phone = phone
|
|
||||||
if settings.get("test_mode", True):
|
|
||||||
send_phone = settings.get("test_phone", "79788382260")
|
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
|
||||||
|
|
||||||
log.info(f"Sending paid_storage SMS to {name} (orig={phone}, send={send_phone})")
|
|
||||||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
|
||||||
|
|
||||||
if sms_id:
|
|
||||||
log_id = insert_sms_log(conn,
|
|
||||||
campaign_type=CAMPAIGN_TYPE,
|
|
||||||
order_group_id=group_id,
|
|
||||||
customer_phone=phone,
|
|
||||||
sms_id=sms_id,
|
|
||||||
sms_text=sms_text,
|
|
||||||
status="sent",
|
|
||||||
sms_code=code,
|
|
||||||
attempts=1,
|
|
||||||
)
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "paid_storage_sending",
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
|
|
||||||
sent_count += 1
|
|
||||||
else:
|
|
||||||
error = raw[:500] if raw else "Unknown error"
|
|
||||||
insert_sms_log(conn,
|
|
||||||
campaign_type=CAMPAIGN_TYPE,
|
|
||||||
order_group_id=group_id,
|
|
||||||
customer_phone=phone,
|
|
||||||
sms_text=sms_text,
|
|
||||||
status="send_failed",
|
|
||||||
sms_code=code,
|
|
||||||
attempts=1,
|
|
||||||
error_message=error,
|
|
||||||
)
|
|
||||||
log.error(f"Group {group_id}: paid_storage SMS failed (code={code}): {error[:200]}")
|
|
||||||
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
|
|
||||||
send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
|
|
||||||
|
|
||||||
return sent_count
|
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
max_duration = settings.get("max_check_duration_minutes", 90)
|
|
||||||
|
|
||||||
sms_list = get_sms_to_check(conn, max_duration)
|
|
||||||
log.info(f"Step 2: {len(sms_list)} paid_storage SMS to check")
|
|
||||||
|
|
||||||
delivered = 0
|
|
||||||
for item in sms_list:
|
|
||||||
log_id = item["log_id"]
|
|
||||||
sms_id = item["sms_id"]
|
|
||||||
group_id = str(item["order_group_id"])
|
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
|
||||||
phone = item.get("customer_phone", "")
|
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
|
||||||
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
|
||||||
|
|
||||||
if code == DELIVERED_CODE:
|
|
||||||
log.info(f"Group {group_id}: paid_storage SMS delivered (103)!")
|
|
||||||
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
|
||||||
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
|
|
||||||
send_telegram(f"✅ SMS платное хранение доставлена: {name} ({phone})", tg_chat)
|
|
||||||
delivered += 1
|
|
||||||
elif code in IN_TRANSIT_CODES:
|
|
||||||
log.info(f"Group {group_id}: in transit (code={code})")
|
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
|
||||||
elif code in DELIVERY_ERROR_CODES:
|
|
||||||
log.error(f"Group {group_id}: delivery error (code={code})")
|
|
||||||
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
|
|
||||||
update_sms_log(conn, log_id, status="expired")
|
|
||||||
# Сброс для retry
|
|
||||||
update_order_group(conn, group_id, {"notification_status": "manual_required"})
|
|
||||||
elif code in LIMIT_ERROR_CODES:
|
|
||||||
log.error(f"Group {group_id}: limit exceeded (code={code})")
|
|
||||||
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}")
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "manual_required",
|
|
||||||
"last_sms_error": f"Limit exceeded (code={code})",
|
|
||||||
})
|
|
||||||
send_telegram(f"🚫 SMS платное хранение заблокирована (лимит): {name} ({phone})", tg_chat)
|
|
||||||
else:
|
|
||||||
log.warning(f"Group {group_id}: unknown code: {code}")
|
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code, error_message=f"Unknown: {code}")
|
|
||||||
|
|
||||||
return delivered
|
|
||||||
|
|
||||||
def step_handle_expired(conn, settings):
|
|
||||||
max_duration = settings.get("max_check_duration_minutes", 90)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
|
|
||||||
expired = get_sms_expired(conn, max_duration)
|
|
||||||
log.info(f"Step 3: {len(expired)} paid_storage SMS expired")
|
|
||||||
|
|
||||||
for item in expired:
|
|
||||||
log_id = item["log_id"]
|
|
||||||
group_id = str(item["order_group_id"])
|
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
|
||||||
phone = item.get("customer_phone", "")
|
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: paid_storage SMS expired")
|
|
||||||
update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
|
|
||||||
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
|
|
||||||
send_telegram(f"⚠️ SMS платное хранение не доставлена: {name} ({phone})", tg_chat)
|
|
||||||
|
|
||||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
log.info("=" * 60)
|
|
||||||
log.info("Paid Storage Campaign — START")
|
|
||||||
conn = get_db_conn()
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = load_settings(conn)
|
|
||||||
log.info(f"Settings: work={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
|
||||||
f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
|
|
||||||
|
|
||||||
if not settings.get("enabled", True):
|
|
||||||
log.info("Campaign disabled, exiting")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Отправка только в рабочие часы
|
|
||||||
work_hours = is_within_work_hours(settings)
|
|
||||||
sent = 0
|
|
||||||
if work_hours:
|
|
||||||
sent = step_send_new(conn, settings)
|
|
||||||
else:
|
|
||||||
log.info("Outside work hours, skipping new SMS sends")
|
|
||||||
|
|
||||||
# Проверка статусов — всегда
|
|
||||||
delivered = step_check_status(conn, settings)
|
|
||||||
step_handle_expired(conn, settings)
|
|
||||||
|
|
||||||
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
|
||||||
|
|
||||||
if sent > 0 or delivered > 0:
|
|
||||||
send_telegram(
|
|
||||||
f"📦 <b>Платное хранение</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
|
||||||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем баланс
|
|
||||||
if sent > 0:
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
balance, raw = fetch_balance(api_id)
|
|
||||||
if balance is not None:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'paid_storage'",
|
|
||||||
(balance,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
log.info(f"Balance updated: {balance} ₽")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Fatal error: {e}", exc_info=True)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log.info("Paid Storage Campaign — END")
|
|
||||||
log.info("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,586 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
SuperSam — SMS Second Campaign (State Machine)
|
|
||||||
Заменяет n8n workflow "Первая проверка согласования + отправка 2й смс"
|
|
||||||
|
|
||||||
Архитектура: state machine через cron (каждые 5 мин)
|
|
||||||
Каждый запуск:
|
|
||||||
1. Отправляет вторую SMS группам, где первая SMS доставлена,
|
|
||||||
но клиент не согласовал доставку, и пришло время next_notification_check_at
|
|
||||||
2. Проверяет статус ранее отправленных вторых SMS
|
|
||||||
3. Обновляет статусы в order_groups + sms_campaign_log
|
|
||||||
|
|
||||||
Защита от повторной отправки (ДВОЙНАЯ):
|
|
||||||
1. После отправки SMS → notification_status = 'second_sms_sending' (не 'first_sms_sent')
|
|
||||||
→ get_groups_to_send НЕ находит эту группу
|
|
||||||
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
|
||||||
3. Код 231/132 = лимит одинаковых → не повторять
|
|
||||||
|
|
||||||
needs_check=true — admin нажал "Проверить снова" в UI
|
|
||||||
→ скрипт проверяет даже если запись старше max_check_duration
|
|
||||||
|
|
||||||
Коды sms.ru:
|
|
||||||
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
|
||||||
103 = доставлено → цель
|
|
||||||
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
|
||||||
DB_PORT = os.environ.get("DB_PORT", "5432")
|
|
||||||
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
|
||||||
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
|
||||||
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
||||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
|
||||||
|
|
||||||
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
|
||||||
SMS_SEND_URL = "https://sms.ru/sms/send"
|
|
||||||
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
|
||||||
|
|
||||||
LOG_FILE = "/var/log/supersam-sms-second.log"
|
|
||||||
|
|
||||||
CAMPAIGN_TYPE = "second_sms"
|
|
||||||
|
|
||||||
# Коды, которые означают "в процессе" (ждём дальше)
|
|
||||||
IN_TRANSIT_CODES = {"100", "101", "102"}
|
|
||||||
# Код доставки
|
|
||||||
DELIVERED_CODE = "103"
|
|
||||||
# Коды ошибок доставки (не временные, можно повторять отправку)
|
|
||||||
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
|
||||||
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
|
||||||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
|
||||||
|
|
||||||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(LOG_FILE),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
log = logging.getLogger("sms_second")
|
|
||||||
|
|
||||||
# ─── БД ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_db_conn():
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
|
||||||
user=DB_USER, password=DB_PASS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_settings(conn):
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
# Fallback на настройки first_sms если second_sms нет
|
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"wait_between_checks_seconds": 25,
|
|
||||||
"max_check_duration_minutes": 90,
|
|
||||||
"max_attempts": 2,
|
|
||||||
"enabled": True,
|
|
||||||
"telegram_chat_id": "25164483",
|
|
||||||
"sms_api_id": SMS_API_ID,
|
|
||||||
"test_mode": True,
|
|
||||||
"test_phone": "79788382260",
|
|
||||||
"second_sms_delay_hours": 3,
|
|
||||||
"auto_manual_after_hours": 3,
|
|
||||||
}
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def normalize_phone(phone):
|
|
||||||
"""Нормализует телефон: только цифры, начинается с 7"""
|
|
||||||
clean = "".join(c for c in str(phone) if c.isdigit())
|
|
||||||
if clean.startswith("8"):
|
|
||||||
clean = "7" + clean[1:]
|
|
||||||
elif not clean.startswith("7"):
|
|
||||||
clean = "7" + clean
|
|
||||||
return clean
|
|
||||||
|
|
||||||
def send_sms(phone, message, api_id):
|
|
||||||
"""Отправляет SMS, возвращает (sms_id, raw_response, code)"""
|
|
||||||
try:
|
|
||||||
clean_phone = normalize_phone(phone)
|
|
||||||
resp = requests.post(SMS_SEND_URL, params={
|
|
||||||
"api_id": api_id,
|
|
||||||
"to": clean_phone,
|
|
||||||
}, data={"msg": message}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
|
||||||
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
status_code = lines[0].strip() if lines else ""
|
|
||||||
|
|
||||||
if status_code == "100" and len(lines) >= 2:
|
|
||||||
sms_id = lines[1].strip()
|
|
||||||
return sms_id, text, "100"
|
|
||||||
else:
|
|
||||||
return None, text, status_code
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS send error: {e}")
|
|
||||||
return None, str(e), "error"
|
|
||||||
|
|
||||||
def check_sms_status(sms_id, api_id):
|
|
||||||
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
|
||||||
try:
|
|
||||||
resp = requests.post(SMS_STATUS_URL, params={
|
|
||||||
"api_id": api_id,
|
|
||||||
"sms_id": sms_id,
|
|
||||||
}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
status_code = lines[0].strip() if lines else ""
|
|
||||||
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
|
||||||
|
|
||||||
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
|
||||||
return sms_status_code, text, status_code
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS status check error: {e}")
|
|
||||||
return None, str(e), "error"
|
|
||||||
|
|
||||||
def fetch_balance(api_id):
|
|
||||||
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
|
|
||||||
try:
|
|
||||||
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
|
||||||
text = resp.text
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
if lines[0].strip() == "100" and len(lines) >= 2:
|
|
||||||
return float(lines[1].strip()), text
|
|
||||||
return None, text
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Balance fetch error: {e}")
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_telegram(message, chat_id):
|
|
||||||
if not TELEGRAM_BOT_TOKEN:
|
|
||||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
||||||
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
log.warning(f"Telegram error: {resp.text[:200]}")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"Telegram failed: {e}")
|
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
|
||||||
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
|
||||||
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
|
||||||
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
|
|
||||||
"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
|
||||||
FROM order_groups og
|
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
|
||||||
AND og.delivery_link IS NOT NULL
|
|
||||||
AND og.delivery_link != ''
|
|
||||||
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
|
||||||
AND og.second_sms_sent_at IS NULL
|
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
|
||||||
-- Нет активной второй SMS в логе
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
|
||||||
WHERE scl.order_group_id = og.id
|
|
||||||
AND scl.campaign_type = 'second_sms'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
|
||||||
)
|
|
||||||
ORDER BY og.created_at ASC
|
|
||||||
""")
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
|
||||||
"""Вторые SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
|
||||||
Включает:
|
|
||||||
- Записи младше max_duration_min (обычная автопроверка)
|
|
||||||
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
|
||||||
"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
|
||||||
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
|
||||||
og.customer_name, og.group_key
|
|
||||||
FROM sms_campaign_log scl
|
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
|
||||||
WHERE scl.campaign_type = 'second_sms'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.sms_id IS NOT NULL
|
|
||||||
AND (
|
|
||||||
scl.created_at > NOW() - INTERVAL '%s minutes'
|
|
||||||
OR scl.needs_check = true
|
|
||||||
)
|
|
||||||
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def get_sms_expired(conn, max_duration_min):
|
|
||||||
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
|
||||||
scl.attempts, scl.created_at,
|
|
||||||
og.customer_name, og.group_key, og.delivery_link
|
|
||||||
FROM sms_campaign_log scl
|
|
||||||
JOIN order_groups og ON og.id = scl.order_group_id
|
|
||||||
WHERE scl.campaign_type = 'second_sms'
|
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.sms_id IS NOT NULL
|
|
||||||
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
|
||||||
ORDER BY scl.created_at ASC
|
|
||||||
""" % max_duration_min)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def insert_sms_log(conn, **kwargs):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cols = ", ".join(kwargs.keys())
|
|
||||||
placeholders = ", ".join(["%s"] * len(kwargs))
|
|
||||||
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
|
||||||
log_id = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
return log_id
|
|
||||||
|
|
||||||
def update_sms_log(conn, log_id, **kwargs):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(log_id)
|
|
||||||
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def update_order_group(conn, group_id, fields):
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in fields.items():
|
|
||||||
if v == "NOW()":
|
|
||||||
set_parts.append(f"{k} = NOW()")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = %s")
|
|
||||||
values.append(v)
|
|
||||||
values.append(group_id)
|
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
|
||||||
"""Проверка: сейчас рабочие часы.
|
|
||||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
|
||||||
"""
|
|
||||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
|
||||||
# work_days: '1,2,3,4,5' → Понедельник=1 ... Воскресенье=7
|
|
||||||
# Python weekday(): 0=Пн ... 6=Вс → конвертируем в 1-7
|
|
||||||
today_num = now_msk.weekday() + 1
|
|
||||||
allowed_days = set()
|
|
||||||
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
|
||||||
for part in str(work_days_str).split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if part.isdigit():
|
|
||||||
allowed_days.add(int(part))
|
|
||||||
if today_num not in allowed_days:
|
|
||||||
return False
|
|
||||||
hour = now_msk.hour
|
|
||||||
start_h = settings.get("work_hours_start", 8)
|
|
||||||
end_h = settings.get("work_hours_end", 21)
|
|
||||||
return start_h <= hour < end_h
|
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def step_send_new(conn, settings):
|
|
||||||
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
|
|
||||||
groups = get_groups_to_send(conn)
|
|
||||||
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
|
||||||
|
|
||||||
sent_count = 0
|
|
||||||
for group in groups:
|
|
||||||
group_id = str(group["id"])
|
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
|
||||||
delivery_link = group.get("delivery_link", "")
|
|
||||||
|
|
||||||
# Защита: была ли уже вторая SMS этой группе за 24h
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
|
||||||
WHERE order_group_id = %s AND campaign_type = 'second_sms'
|
|
||||||
AND created_at > NOW() - INTERVAL '24 hours'
|
|
||||||
AND status IN ('sent', 'checking', 'delivered')
|
|
||||||
""", (group_id,))
|
|
||||||
recent_count = cur.fetchone()[0]
|
|
||||||
if recent_count > 0:
|
|
||||||
log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
|
|
||||||
|
|
||||||
# ТЕСТОВЫЙ РЕЖИМ
|
|
||||||
send_phone = phone
|
|
||||||
if settings.get("test_mode", True):
|
|
||||||
send_phone = settings.get("test_phone", "79788382260")
|
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
|
||||||
|
|
||||||
log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
|
|
||||||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
|
||||||
|
|
||||||
if sms_id:
|
|
||||||
log_id = insert_sms_log(conn,
|
|
||||||
campaign_type=CAMPAIGN_TYPE,
|
|
||||||
order_group_id=group_id,
|
|
||||||
customer_phone=phone,
|
|
||||||
sms_id=sms_id,
|
|
||||||
sms_text=sms_text,
|
|
||||||
status="sent",
|
|
||||||
sms_code=code,
|
|
||||||
attempts=1,
|
|
||||||
)
|
|
||||||
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "second_sms_sending",
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: second SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→second_sms_sending")
|
|
||||||
sent_count += 1
|
|
||||||
else:
|
|
||||||
error = raw[:500] if raw else "Unknown error"
|
|
||||||
log_id = insert_sms_log(conn,
|
|
||||||
campaign_type=CAMPAIGN_TYPE,
|
|
||||||
order_group_id=group_id,
|
|
||||||
customer_phone=phone,
|
|
||||||
sms_text=sms_text,
|
|
||||||
status="send_failed",
|
|
||||||
sms_code=code,
|
|
||||||
attempts=1,
|
|
||||||
error_message=error,
|
|
||||||
)
|
|
||||||
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"last_sms_error": error[:200],
|
|
||||||
})
|
|
||||||
send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
|
||||||
|
|
||||||
return sent_count
|
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
|
||||||
"""Шаг 2: Проверка статуса ранее отправленных вторых SMS"""
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
max_duration = settings.get("max_check_duration_minutes", 90)
|
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
|
||||||
auto_manual_hours = settings.get("auto_manual_after_hours", 3)
|
|
||||||
|
|
||||||
sms_list = get_sms_to_check(conn, max_duration)
|
|
||||||
log.info(f"Step 2: {len(sms_list)} second SMS to check status")
|
|
||||||
|
|
||||||
delivered = 0
|
|
||||||
for item in sms_list:
|
|
||||||
log_id = item["log_id"]
|
|
||||||
sms_id = item["sms_id"]
|
|
||||||
group_id = str(item["order_group_id"])
|
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
|
||||||
phone = item.get("customer_phone", "")
|
|
||||||
attempts = item.get("attempts", 1)
|
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
|
||||||
|
|
||||||
# Сбрасываем needs_check + ставим checked_at
|
|
||||||
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
|
||||||
|
|
||||||
if code == DELIVERED_CODE:
|
|
||||||
# Доставлено!
|
|
||||||
log.info(f"Group {group_id}: second SMS delivered (103)!")
|
|
||||||
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
|
||||||
|
|
||||||
next_check = (datetime.now(timezone.utc) + timedelta(hours=auto_manual_hours)).isoformat()
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "second_sms_sent",
|
|
||||||
"second_sms_sent_at": "NOW()",
|
|
||||||
"sms_attempts": attempts,
|
|
||||||
"last_sms_error": None,
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
"next_notification_check_at": next_check,
|
|
||||||
"status": "second_sms_sent",
|
|
||||||
})
|
|
||||||
send_telegram(f"✅ Вторая SMS доставлена: {name} ({phone})", tg_chat)
|
|
||||||
delivered += 1
|
|
||||||
|
|
||||||
elif code in IN_TRANSIT_CODES:
|
|
||||||
# В пути / в очереди — продолжаем ждать
|
|
||||||
log.info(f"Group {group_id}: second SMS in transit (code={code}), will check again next run")
|
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
|
||||||
|
|
||||||
elif code in DELIVERY_ERROR_CODES:
|
|
||||||
# Ошибка доставки — можно повторить отправку
|
|
||||||
log.error(f"Group {group_id}: second SMS delivery error (code={code})")
|
|
||||||
update_sms_log(conn, log_id, status="error", sms_code=code,
|
|
||||||
error_message=f"Delivery error: {code}")
|
|
||||||
if attempts < max_attempts:
|
|
||||||
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}/{max_attempts})")
|
|
||||||
update_sms_log(conn, log_id, status="expired")
|
|
||||||
# Сбрасываем на first_sms_sent для повторной отправки
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "first_sms_sent",
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "manual_required",
|
|
||||||
"last_sms_error": f"Second SMS failed after {max_attempts} attempts (code={code})",
|
|
||||||
})
|
|
||||||
send_telegram(f"⚠️ Вторая SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
|
||||||
|
|
||||||
elif code in LIMIT_ERROR_CODES:
|
|
||||||
# Превышен лимит — НЕ повторять
|
|
||||||
log.error(f"Group {group_id}: second SMS limit exceeded (code={code}) — NOT retrying")
|
|
||||||
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
|
|
||||||
error_message=f"Limit exceeded: code={code}")
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "manual_required",
|
|
||||||
"last_sms_error": f"Second SMS limit exceeded (code={code})",
|
|
||||||
})
|
|
||||||
send_telegram(f"🚫 Вторая SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Неизвестный код — логируем, продолжаем проверять
|
|
||||||
log.warning(f"Group {group_id}: unknown SMS code for second SMS: {code}")
|
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code,
|
|
||||||
error_message=f"Unknown code: {code}")
|
|
||||||
|
|
||||||
return delivered
|
|
||||||
|
|
||||||
def step_handle_expired(conn, settings):
|
|
||||||
"""Шаг 3: Обработка вторых SMS с истёкшим сроком проверки"""
|
|
||||||
max_duration = settings.get("max_check_duration_minutes", 90)
|
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
|
||||||
|
|
||||||
expired = get_sms_expired(conn, max_duration)
|
|
||||||
log.info(f"Step 3: {len(expired)} second SMS expired (older than {max_duration} min)")
|
|
||||||
|
|
||||||
for item in expired:
|
|
||||||
log_id = item["log_id"]
|
|
||||||
group_id = str(item["order_group_id"])
|
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
|
||||||
phone = item.get("customer_phone", "")
|
|
||||||
attempts = item.get("attempts", 1)
|
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
|
||||||
update_sms_log(conn, log_id, status="expired",
|
|
||||||
error_message=f"Not delivered in {max_duration} minutes")
|
|
||||||
|
|
||||||
if attempts < max_attempts:
|
|
||||||
# Сбрасываем на first_sms_sent для повторной отправки
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "first_sms_sent",
|
|
||||||
})
|
|
||||||
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}), notification_status→first_sms_sent")
|
|
||||||
else:
|
|
||||||
# Все попытки исчерпаны → ручное управление
|
|
||||||
update_order_group(conn, group_id, {
|
|
||||||
"notification_status": "manual_required",
|
|
||||||
"last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
|
|
||||||
})
|
|
||||||
send_telegram(
|
|
||||||
f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
|
|
||||||
f"Вторая SMS не доставлена после {max_attempts} попыток",
|
|
||||||
tg_chat,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
log.info("=" * 60)
|
|
||||||
log.info("SMS Second Campaign — START")
|
|
||||||
conn = get_db_conn()
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = load_settings(conn)
|
|
||||||
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
|
||||||
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
|
||||||
f"max_attempts={settings.get('max_attempts')}")
|
|
||||||
|
|
||||||
if not settings.get("enabled", True):
|
|
||||||
log.info("Campaign disabled, exiting")
|
|
||||||
return
|
|
||||||
|
|
||||||
# State machine — каждый шаг быстрый, без blocking
|
|
||||||
# Отправка только в рабочие часы (8-21, Пн-Пт по Москве)
|
|
||||||
work_hours = is_within_work_hours(settings)
|
|
||||||
sent = 0
|
|
||||||
if work_hours:
|
|
||||||
sent = step_send_new(conn, settings)
|
|
||||||
else:
|
|
||||||
log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends")
|
|
||||||
|
|
||||||
# Проверка статусов работает всегда — даже ночью
|
|
||||||
delivered = step_check_status(conn, settings)
|
|
||||||
step_handle_expired(conn, settings)
|
|
||||||
|
|
||||||
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
|
||||||
|
|
||||||
if sent > 0 or delivered > 0:
|
|
||||||
send_telegram(
|
|
||||||
f"📊 <b>Вторая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
|
||||||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем баланс sms.ru
|
|
||||||
if sent > 0:
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
|
||||||
balance, raw = fetch_balance(api_id)
|
|
||||||
if balance is not None:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'second_sms'",
|
|
||||||
(balance,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
log.info(f"Balance updated: {balance} ₽")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Fatal error: {e}", exc_info=True)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log.info("SMS Second Campaign — END")
|
|
||||||
log.info("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,46 +1,22 @@
|
||||||
/**
|
/**
|
||||||
* @file SmsCampaignPanel.jsx
|
* @file SmsCampaignPanel.jsx
|
||||||
* @description SMS Campaign management for mega_admin.
|
* @description SMS Campaign log + settings for mega_admin.
|
||||||
* Campaign selector cards + settings + log table + "Проверить снова" button
|
* Shows sms_campaign_log entries with filtering and sms_campaign_settings editable.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
|
||||||
|
|
||||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
// ── Status labels ──────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
|
||||||
{ key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" },
|
|
||||||
{ key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" },
|
|
||||||
{ key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" },
|
|
||||||
{ key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
|
|
||||||
|
|
||||||
// ── Days of week ─────────────────────────────────────────────────────────────
|
|
||||||
const DAYS = [
|
|
||||||
{ num: 1, short: "Пн" },
|
|
||||||
{ num: 2, short: "Вт" },
|
|
||||||
{ num: 3, short: "Ср" },
|
|
||||||
{ num: 4, short: "Чт" },
|
|
||||||
{ num: 5, short: "Пт" },
|
|
||||||
{ num: 6, short: "Сб" },
|
|
||||||
{ num: 7, short: "Вс" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Status labels ────────────────────────────────────────────────────────────
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
sent: "Отправлено",
|
sent: "Отправлено",
|
||||||
checking: "Проверяется",
|
checking: "Проверяется",
|
||||||
delivered: "Доставлено",
|
delivered: "Доставлено",
|
||||||
send_failed: "Ошибка отправки",
|
send_failed: "Ошибка отправки",
|
||||||
error: "Ошибка доставки",
|
error: "Ошибка доставки",
|
||||||
expired: "Истёк срок",
|
expired: "Истекло",
|
||||||
limit_exceeded: "Лимит превышен",
|
limit_exceeded: "Лимит превышен",
|
||||||
manual_override: "Ручной режим",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_TONES = {
|
const STATUS_TONES = {
|
||||||
|
|
@ -51,28 +27,36 @@ const STATUS_TONES = {
|
||||||
error: "danger",
|
error: "danger",
|
||||||
expired: "warning",
|
expired: "warning",
|
||||||
limit_exceeded: "danger",
|
limit_exceeded: "danger",
|
||||||
manual_override: "warning",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── SMS code labels ──────────────────────────────────────────────────────────
|
// ── SMS code labels (from sms.ru docs) ────────────────────────────────────────
|
||||||
const SMS_CODE_LABELS = {
|
const SMS_CODE_LABELS = {
|
||||||
"100": "В очереди", "101": "Оператору", "102": "В пути",
|
"100": "В очереди",
|
||||||
"103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
|
"101": "Оператору",
|
||||||
"106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
|
"102": "В пути",
|
||||||
"130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
|
"103": "Доставлено",
|
||||||
"132": "Лимит одинаковых/день", "200": "Неправильный api_id",
|
"104": "Истекло время",
|
||||||
"201": "Недостаточно средств", "202": "Неправильный получатель",
|
"105": "Удалено оператором",
|
||||||
"230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
|
"106": "Сбой телефона",
|
||||||
|
"107": "Неизвестная причина",
|
||||||
|
"108": "Отклонено",
|
||||||
|
"130": "Лимит на номер/день",
|
||||||
|
"131": "Лимит одинаковых/мин",
|
||||||
|
"132": "Лимит одинаковых/день",
|
||||||
|
"200": "Неправильный api_id",
|
||||||
|
"201": "Недостаточно средств",
|
||||||
|
"202": "Неправильный получатель",
|
||||||
|
"230": "Общий лимит/день",
|
||||||
|
"231": "Лимит одинаковых/мин",
|
||||||
"232": "Лимит одинаковых/день",
|
"232": "Лимит одинаковых/день",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
const fmtTime = (ts) => {
|
const fmtTime = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
try {
|
try {
|
||||||
return new Date(ts).toLocaleString("ru-RU", {
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
day: "2-digit", month: "2-digit", year: "2-digit",
|
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||||
hour: "2-digit", minute: "2-digit",
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
});
|
});
|
||||||
} catch { return ts; }
|
} catch { return ts; }
|
||||||
};
|
};
|
||||||
|
|
@ -86,131 +70,51 @@ const fmtPhone = (phone) => {
|
||||||
return phone;
|
return phone;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtElapsed = (ts) => {
|
|
||||||
if (!ts) return "—";
|
|
||||||
const diff = Date.now() - new Date(ts).getTime();
|
|
||||||
const m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "только что";
|
|
||||||
if (m < 60) return `${m} мин назад`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
const rest = m % 60;
|
|
||||||
return `${h}ч ${rest}м назад`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseWorkDays = (str) => {
|
|
||||||
if (!str) return new Set([1, 2, 3, 4, 5]);
|
|
||||||
return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7));
|
|
||||||
};
|
|
||||||
|
|
||||||
const serializeWorkDays = (set) => [...set].sort().join(",");
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────────────
|
|
||||||
export const SmsCampaignPanel = () => {
|
export const SmsCampaignPanel = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [activeCampaign, setActiveCampaign] = useState("first_sms");
|
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [manualGroups, setManualGroups] = useState([]);
|
|
||||||
const [settings, setSettings] = useState(null);
|
const [settings, setSettings] = useState(null);
|
||||||
const [allSettings, setAllSettings] = useState({});
|
|
||||||
const [campaignCounts, setCampaignCounts] = useState({});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||||
const [checkingIds, setCheckingIds] = useState(new Set());
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
||||||
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
|
||||||
const refreshTimer = useRef(null);
|
|
||||||
|
|
||||||
const handleOpenGroup = useCallback((groupId) => {
|
|
||||||
if (groupId) navigate("/dashboard/group/" + groupId);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
// ── Load data ──────────────────────────────────────────────────────────────
|
// ── Load data ──────────────────────────────────────────────────────────────
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Load all settings for cards
|
// Load logs
|
||||||
const { data: allSettingsData, error: allErr } = await supabase
|
|
||||||
.from("sms_campaign_settings")
|
|
||||||
.select("*");
|
|
||||||
if (allErr) throw allErr;
|
|
||||||
const settingsMap = {};
|
|
||||||
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
|
||||||
setAllSettings(settingsMap);
|
|
||||||
|
|
||||||
// Load counts per campaign (for cards)
|
|
||||||
const { data: countsData, error: countsErr } = await supabase
|
|
||||||
.from("sms_campaign_log")
|
|
||||||
.select("campaign_type, status");
|
|
||||||
if (countsErr) throw countsErr;
|
|
||||||
const counts = {};
|
|
||||||
(countsData || []).forEach(r => {
|
|
||||||
if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 };
|
|
||||||
counts[r.campaign_type].total++;
|
|
||||||
if (r.status === "delivered") counts[r.campaign_type].delivered++;
|
|
||||||
if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++;
|
|
||||||
if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also count manual_required groups for the manual card
|
|
||||||
const { count: manualCount, error: manualCountErr } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("id", { count: "exact", head: true })
|
|
||||||
.eq("notification_status", "manual_required");
|
|
||||||
if (!manualCountErr && manualCount != null) {
|
|
||||||
counts["manual"] = counts["manual"] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
|
||||||
counts["manual"].total = manualCount;
|
|
||||||
}
|
|
||||||
setCampaignCounts(counts);
|
|
||||||
|
|
||||||
// For "manual" campaign: load order_groups with manual_required status
|
|
||||||
if (activeCampaign === "manual") {
|
|
||||||
const { data: manualData, error: manualErr } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("id, customer_name, customer_phone, notification_status, first_sms_sent_at, second_sms_sent_at, next_notification_check_at, sms_attempts, last_sms_error, delivery_status, delivery_link, created_at, updated_at")
|
|
||||||
.eq("notification_status", "manual_required")
|
|
||||||
.order("updated_at", { ascending: false })
|
|
||||||
.limit(200);
|
|
||||||
if (manualErr) throw manualErr;
|
|
||||||
setManualGroups(manualData || []);
|
|
||||||
setLogs([]); // No SMS logs for manual campaign
|
|
||||||
} else {
|
|
||||||
setManualGroups([]);
|
|
||||||
// Load logs for active campaign
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("sms_campaign_log")
|
.from("sms_campaign_log")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("campaign_type", activeCampaign)
|
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(200);
|
.limit(200);
|
||||||
if (filter !== "all") query = query.eq("status", filter);
|
|
||||||
|
if (filter !== "all") {
|
||||||
|
query = query.eq("status", filter);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: logData, error: logError } = await query;
|
const { data: logData, error: logError } = await query;
|
||||||
if (logError) throw logError;
|
if (logError) throw logError;
|
||||||
setLogs(logData || []);
|
setLogs(logData || []);
|
||||||
}
|
|
||||||
|
|
||||||
// Load settings for active campaign
|
// Load settings
|
||||||
if (HAS_SETTINGS.includes(activeCampaign)) {
|
const { data: settingsData, error: settingsError } = await supabase
|
||||||
setSettings(settingsMap[activeCampaign] || null);
|
.from("sms_campaign_settings")
|
||||||
} else {
|
.select("*")
|
||||||
setSettings(null);
|
.eq("campaign_type", "first_sms")
|
||||||
}
|
.single();
|
||||||
|
if (settingsError && settingsError.code !== "PGRST116") throw settingsError;
|
||||||
|
setSettings(settingsData || null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || String(e));
|
setError(e.message || String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeCampaign, filter]);
|
}, [filter]);
|
||||||
|
|
||||||
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
useEffect(() => {
|
|
||||||
if (autoRefresh) {
|
|
||||||
refreshTimer.current = setInterval(() => loadData(), 30000);
|
|
||||||
return () => clearInterval(refreshTimer.current);
|
|
||||||
}
|
|
||||||
}, [autoRefresh, loadData]);
|
|
||||||
|
|
||||||
// ── Save settings ──────────────────────────────────────────────────────────
|
// ── Save settings ──────────────────────────────────────────────────────────
|
||||||
const handleSaveSettings = async () => {
|
const handleSaveSettings = async () => {
|
||||||
|
|
@ -237,196 +141,45 @@ export const SmsCampaignPanel = () => {
|
||||||
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
|
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Quick toggle campaign on/off from card ────────────────────────────────
|
// ── Stats summary ─────────────────────────────────────────────────────────
|
||||||
const toggleCampaignEnabled = async (campaignKey) => {
|
|
||||||
const s = allSettings[campaignKey];
|
|
||||||
if (!s) return;
|
|
||||||
setTogglingCampaign(campaignKey);
|
|
||||||
const newVal = !(s.enabled ?? true);
|
|
||||||
try {
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("sms_campaign_settings")
|
|
||||||
.update({ enabled: newVal, updated_at: new Date().toISOString() })
|
|
||||||
.eq("id", s.id);
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
// Update local state
|
|
||||||
setAllSettings(prev => ({
|
|
||||||
...prev,
|
|
||||||
[campaignKey]: { ...prev[campaignKey], enabled: newVal }
|
|
||||||
}));
|
|
||||||
if (campaignKey === activeCampaign) {
|
|
||||||
setSettings(prev => prev ? { ...prev, enabled: newVal } : prev);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(`Ошибка: ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
setTogglingCampaign(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleWorkDay = (dayNum) => {
|
|
||||||
const current = parseWorkDays(settings?.work_days);
|
|
||||||
if (current.has(dayNum)) current.delete(dayNum);
|
|
||||||
else current.add(dayNum);
|
|
||||||
updateSetting("work_days", serializeWorkDays(current));
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Recheck ────────────────────────────────────────────────────────────────
|
|
||||||
const handleRecheck = async (logId) => {
|
|
||||||
setCheckingIds(prev => new Set([...prev, logId]));
|
|
||||||
try {
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("sms_campaign_log")
|
|
||||||
.update({ needs_check: true, updated_at: new Date().toISOString() })
|
|
||||||
.eq("id", logId);
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l));
|
|
||||||
} catch (e) {
|
|
||||||
setError(`Ошибка: ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; });
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
|
||||||
const stats = logs.reduce((acc, log) => {
|
const stats = logs.reduce((acc, log) => {
|
||||||
acc[log.status] = (acc[log.status] || 0) + 1;
|
acc[log.status] = (acc[log.status] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const showSettings = HAS_SETTINGS.includes(activeCampaign);
|
|
||||||
const isManualCampaign = activeCampaign === "manual";
|
|
||||||
const isPaidStorageCampaign = activeCampaign === "paid_storage";
|
|
||||||
const showSmsFields = !isManualCampaign;
|
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
if (isLoading && logs.length === 0) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Panel className="p-5">
|
<Panel className="p-5">
|
||||||
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка…</div>
|
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка SMS-логов…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="text-sm text-[var(--color-danger)]">Ошибка: {error}</div>
|
||||||
|
<button onClick={loadData} className="mt-3 rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface-strong)]">
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
{/* Settings panel */}
|
||||||
{/* Balance banner */}
|
{settings && (
|
||||||
{allSettings.first_sms?.last_balance != null && (
|
|
||||||
<Panel className="p-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">💰</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
|
||||||
<div className="text-lg font-bold text-[var(--color-text)]">
|
|
||||||
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Campaign cards */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{CAMPAIGNS.map(c => {
|
|
||||||
const isActive = activeCampaign === c.key;
|
|
||||||
const s = allSettings[c.key];
|
|
||||||
const isEnabled = s?.enabled ?? true;
|
|
||||||
const isTest = s?.test_mode ?? true;
|
|
||||||
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
|
||||||
const hasS = HAS_SETTINGS.includes(c.key);
|
|
||||||
const isToggling = togglingCampaign === c.key;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={c.key}
|
|
||||||
className={`rounded-2xl border p-4 transition cursor-pointer ${
|
|
||||||
isActive
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
|
|
||||||
}`}
|
|
||||||
onClick={() => { setActiveCampaign(c.key); setFilter("all"); }}
|
|
||||||
>
|
|
||||||
{/* Header: icon + toggle */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-2xl">{c.icon}</span>
|
|
||||||
{hasS && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.stopPropagation(); toggleCampaignEnabled(c.key); }}
|
|
||||||
disabled={isToggling}
|
|
||||||
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
|
||||||
>
|
|
||||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
|
|
||||||
|
|
||||||
{/* Status badge */}
|
|
||||||
{hasS && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
|
||||||
isEnabled
|
|
||||||
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
|
|
||||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
|
||||||
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick stats */}
|
|
||||||
{hasS && cnt.total > 0 && (
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-1 text-center">
|
|
||||||
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
|
||||||
<div className="text-[10px] text-[var(--color-text-muted)]">Всего</div>
|
|
||||||
<div className="text-xs font-bold text-[var(--color-text)]">{cnt.total}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
|
||||||
<div className="text-[10px] text-[var(--color-text-muted)]">Доставл.</div>
|
|
||||||
<div className="text-xs font-bold text-[#22c55e]">{cnt.delivered}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
|
||||||
<div className="text-[10px] text-[var(--color-text-muted)]">Ошибок</div>
|
|
||||||
<div className="text-xs font-bold text-[#ef4444]">{cnt.errors}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasS && (
|
|
||||||
<div className="mt-2 text-[11px] text-[var(--color-text-muted)] leading-snug">В разработке</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Panel className="p-3">
|
|
||||||
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
|
||||||
<button onClick={() => { setError(null); loadData(); }} className="mt-2 text-xs text-[var(--color-accent)]">Повторить</button>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
|
||||||
<SmsCampaignStats campaignType={activeCampaign} />
|
|
||||||
|
|
||||||
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
|
||||||
{showSettings && settings && (
|
|
||||||
<Panel className="p-5">
|
<Panel className="p-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">Настройки кампании</h3>
|
||||||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}
|
{settingsSaved && (
|
||||||
</h3>
|
<span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>
|
||||||
{settingsSaved && <span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test / Production toggle — только для SMS-кампаний */}
|
{/* Test / Production mode toggle */}
|
||||||
{!isManualCampaign && (
|
|
||||||
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
|
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
|
||||||
<label className="flex items-center justify-between">
|
<label className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -435,8 +188,8 @@ export const SmsCampaignPanel = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||||||
{settings.test_mode
|
{settings.test_mode
|
||||||
? `SMS только на: ${settings.test_phone || "—"}`
|
? `SMS отправляются только на тестовый номер: ${settings.test_phone || "—"}`
|
||||||
: "SMS реальным клиентам из базы"}
|
: "SMS отправляются реальным клиентам из базы"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -449,116 +202,60 @@ export const SmsCampaignPanel = () => {
|
||||||
</label>
|
</label>
|
||||||
{settings.test_mode && (
|
{settings.test_mode && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
|
<SettingField
|
||||||
</div>
|
label="Тестовый номер телефона"
|
||||||
)}
|
value={settings.test_phone || ""}
|
||||||
</div>
|
onChange={(v) => updateSetting("test_phone", v)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── SMS text template ──────────────────────────────────────────── */}
|
|
||||||
{!isManualCampaign && (
|
|
||||||
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
|
||||||
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
|
|
||||||
<textarea
|
|
||||||
value={settings.sms_text_template || ""}
|
|
||||||
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
|
||||||
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Интервалы ─────────────────────────────────────────────────── */}
|
|
||||||
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">
|
|
||||||
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{!isManualCampaign && (
|
<SettingField
|
||||||
<>
|
label="Пауза между проверками (сек)"
|
||||||
<SettingField label="Пауза между проверками (сек)" value={settings.wait_between_checks_seconds} onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
|
value={settings.wait_between_checks_seconds}
|
||||||
<SettingField label="Макс. время ожидания (мин)" value={settings.max_check_duration_minutes} onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
|
onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)}
|
||||||
<SettingField label="Макс. попыток отправки" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)} />
|
/>
|
||||||
{!isPaidStorageCampaign && (
|
<SettingField
|
||||||
<SettingField label="Вторая SMS через (часов)" value={settings.second_sms_delay_hours} onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
|
label="Макс. время ожидания (мин)"
|
||||||
)}
|
value={settings.max_check_duration_minutes}
|
||||||
</>
|
onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)}
|
||||||
)}
|
/>
|
||||||
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
|
<SettingField
|
||||||
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
|
label="Макс. попыток отправки"
|
||||||
|
value={settings.max_attempts}
|
||||||
|
onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)}
|
||||||
|
/>
|
||||||
|
<SettingField
|
||||||
|
label="Вторая SMS через (часов)"
|
||||||
|
value={settings.second_sms_delay_hours}
|
||||||
|
onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)}
|
||||||
|
/>
|
||||||
|
<SettingField
|
||||||
|
label="Ручное согласование через (часов)"
|
||||||
|
value={settings.auto_manual_after_hours}
|
||||||
|
onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)}
|
||||||
|
/>
|
||||||
|
<SettingField
|
||||||
|
label="Telegram chat ID"
|
||||||
|
value={settings.telegram_chat_id || ""}
|
||||||
|
onChange={(v) => updateSetting("telegram_chat_id", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Время работы ────────────────────────────────────────────── */}
|
|
||||||
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
|
||||||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">
|
|
||||||
{isManualCampaign ? "⏰ Время проверки" : "⏰ Время отправки SMS"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Часы */}
|
|
||||||
<div className="mb-3 flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={settings.work_hours_start ?? 8}
|
|
||||||
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
|
|
||||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
|
||||||
</select>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
|
||||||
<select
|
|
||||||
value={settings.work_hours_end ?? 21}
|
|
||||||
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
|
|
||||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
|
||||||
</select>
|
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Дни недели — таблетки */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DAYS.map(d => {
|
|
||||||
const activeDays = parseWorkDays(settings.work_days);
|
|
||||||
const isOn = activeDays.has(d.num);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={d.num}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleWorkDay(d.num)}
|
|
||||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
|
||||||
isOn
|
|
||||||
? "bg-[var(--color-accent)] text-white"
|
|
||||||
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{d.short}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
Проверка статусов работает круглосуточно. Отправка — только в выбранные часы и дни.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Вкл/выкл + сохранить ──────────────────────────────────────── */}
|
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
<button
|
<label className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
type="button"
|
<input
|
||||||
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
|
type="checkbox"
|
||||||
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
checked={settings.enabled ?? true}
|
||||||
>
|
onChange={(e) => updateSetting("enabled", e.target.checked)}
|
||||||
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
|
className="h-4 w-4 rounded border-[var(--color-border)]"
|
||||||
</button>
|
/>
|
||||||
<span className="text-xs font-medium text-[var(--color-text)]">
|
Кампания активна
|
||||||
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
|
</label>
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveSettings}
|
onClick={handleSaveSettings}
|
||||||
disabled={savingSettings}
|
disabled={savingSettings}
|
||||||
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
className="rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{savingSettings ? "Сохранение…" : "Сохранить"}
|
{savingSettings ? "Сохранение…" : "Сохранить"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -566,57 +263,87 @@ export const SmsCampaignPanel = () => {
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
{/* Stats summary */}
|
||||||
{!showSettings && (
|
|
||||||
<Panel className="p-5">
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Manual campaign: groups table ─────────────────────────────────── */}
|
|
||||||
{isManualCampaign ? (
|
|
||||||
<>
|
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-semibold text-[var(--color-text-muted)]">В ручном управлении: {manualGroups.length}</span>
|
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
|
const count = stats[status] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<Badge key={status} tone={STATUS_TONES[status] || "neutral"}>
|
||||||
|
{label}: {count}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Filter buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<FilterButton active={filter === "all"} onClick={() => setFilter("all")}>
|
||||||
|
Все ({logs.length})
|
||||||
|
</FilterButton>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
|
const count = stats[status] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}>
|
||||||
|
{label} ({count})
|
||||||
|
</FilterButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log table */}
|
||||||
<Panel className="p-0">
|
<Panel className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-[800px]">
|
<div className="min-w-[800px]">
|
||||||
<div className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
{/* Header */}
|
||||||
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
<div className="grid grid-cols-[minmax(120px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(80px,1fr)_minmax(100px,1.2fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
||||||
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
||||||
<div className="px-3 py-1.5 font-medium">1-я SMS</div>
|
<div className="px-3 py-1.5 font-medium">SMS ID</div>
|
||||||
<div className="px-3 py-1.5 font-medium">2-я SMS</div>
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
<div className="px-3 py-1.5 font-medium">Код</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Попытка</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Создано</div>
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Нет записей в логе
|
||||||
</div>
|
</div>
|
||||||
{manualGroups.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
|
|
||||||
) : (
|
) : (
|
||||||
manualGroups.map((group) => (
|
logs.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={group.id}
|
key={entry.id}
|
||||||
onClick={() => handleOpenGroup(group.id)}
|
className="grid grid-cols-[minmax(120px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(80px,1fr)_minmax(100px,1.2fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)]"
|
||||||
className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
<div className="font-medium">{group.customer_name || "—"}</div>
|
{fmtPhone(entry.customer_phone)}
|
||||||
{group.last_sms_error && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{group.last_sms_error.slice(0, 60)}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{fmtPhone(group.customer_phone)}</div>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
|
||||||
{group.first_sms_sent_at ? fmtTime(group.first_sms_sent_at) : "—"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
{group.second_sms_sent_at ? fmtTime(group.second_sms_sent_at) : <span className="text-[var(--color-danger)]">не отправлена</span>}
|
{entry.sms_id || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>
|
||||||
|
{STATUS_LABELS[entry.status] || entry.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]" title={SMS_CODE_LABELS[entry.sms_code] || ""}>
|
||||||
|
{entry.sms_code || "—"}
|
||||||
|
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && (
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">{SMS_CODE_LABELS[entry.sms_code]}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
{fmtTime(group.updated_at)}
|
{entry.attempts || 0}
|
||||||
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{fmtTime(entry.created_at)}
|
||||||
|
{entry.error_message && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -624,115 +351,22 @@ export const SmsCampaignPanel = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* ── Stats ─────────────────────────────────────────────────────────── */}
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
|
|
||||||
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
|
||||||
const count = stats[status] || 0;
|
|
||||||
if (count === 0) return null;
|
|
||||||
return <Badge key={status} tone={STATUS_TONES[status] || "neutral"}>{label}: {count}</Badge>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<FilterButton active={filter === "all"} onClick={() => setFilter("all")}>Все ({logs.length})</FilterButton>
|
|
||||||
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
|
||||||
const count = stats[status] || 0;
|
|
||||||
if (count === 0) return null;
|
|
||||||
return <FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}>{label} ({count})</FilterButton>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} className="h-3.5 w-3.5 rounded border-[var(--color-border)]" />
|
|
||||||
Авто-обновление (30с)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Log table ──────────────────────────────────────────────────────── */}
|
|
||||||
<Panel className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="min-w-[900px]">
|
|
||||||
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
|
||||||
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">SMS ID</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Статус</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Код</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Поп.</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Создано</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Действие</div>
|
|
||||||
</div>
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
|
|
||||||
) : (
|
|
||||||
logs.map((entry) => {
|
|
||||||
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
|
||||||
const isChecking = checkingIds.has(entry.id);
|
|
||||||
const isTestMode = settings?.test_mode;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
onClick={() => handleOpenGroup(entry.order_group_id)}
|
|
||||||
className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
|
||||||
{fmtPhone(entry.customer_phone)}
|
|
||||||
{isTestMode && <div className="text-[10px] text-[var(--color-warning)]">🧪 на тест. номер</div>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.sms_id || "—"}</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>{STATUS_LABELS[entry.status] || entry.status}</Badge>
|
|
||||||
{entry.needs_check && <div className="mt-0.5 text-[10px] text-[var(--color-accent)]">⟳ в очереди</div>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]" title={SMS_CODE_LABELS[entry.sms_code] || ""}>
|
|
||||||
{entry.sms_code || "—"}
|
|
||||||
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && <div className="text-[10px]">{SMS_CODE_LABELS[entry.sms_code]}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.attempts || 0}</div>
|
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
|
||||||
{fmtTime(entry.created_at)}
|
|
||||||
<div className="text-[10px]">{fmtElapsed(entry.created_at)}</div>
|
|
||||||
{entry.error_message && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{canRecheck ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRecheck(entry.id)}
|
|
||||||
disabled={isChecking || entry.needs_check}
|
|
||||||
className="rounded-lg border border-[var(--color-border)] px-2 py-1 text-[11px] font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{isChecking ? "…" : entry.needs_check ? "⟳ В очереди" : "↻ Проверить"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">→ к доставке</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
|
{/* Refresh button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
>
|
||||||
↻ Обновить
|
↻ Обновить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helper components ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SettingField = ({ label, value, onChange }) => (
|
const SettingField = ({ label, value, onChange }) => (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
/**
|
|
||||||
* @file SmsCampaignStats.jsx
|
|
||||||
* @description SMS Campaign statistics with charts:
|
|
||||||
* - KPI cards (sent, delivered, in transit, errors)
|
|
||||||
* - Daily trend (sent vs delivered, last 14 days)
|
|
||||||
* - Status donut chart
|
|
||||||
* - Delivery rate
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
|
||||||
PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
import { supabase } from "../../supabaseClient";
|
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
|
||||||
delivered: "#22c55e",
|
|
||||||
sent: "#3b82f6",
|
|
||||||
checking: "#94a3b8",
|
|
||||||
expired: "#eab308",
|
|
||||||
send_failed: "#ef4444",
|
|
||||||
error: "#ef4444",
|
|
||||||
limit_exceeded: "#f97316",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
|
||||||
delivered: "Доставлено",
|
|
||||||
sent: "Отправлено",
|
|
||||||
checking: "Проверяется",
|
|
||||||
expired: "Истёк срок",
|
|
||||||
send_failed: "Ошибка отправки",
|
|
||||||
error: "Ошибка доставки",
|
|
||||||
limit_exceeded: "Лимит",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SmsCampaignStats = ({ campaignType }) => {
|
|
||||||
const [stats, setStats] = useState(null);
|
|
||||||
const [dailyData, setDailyData] = useState([]);
|
|
||||||
const [statusData, setStatusData] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [period, setPeriod] = useState("14d");
|
|
||||||
|
|
||||||
const loadStats = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14;
|
|
||||||
const startDate = new Date();
|
|
||||||
startDate.setDate(startDate.getDate() - days);
|
|
||||||
const startDateISO = startDate.toISOString();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("sms_campaign_log")
|
|
||||||
.select("status, sms_code, attempts, created_at, campaign_type")
|
|
||||||
.eq("campaign_type", campaignType)
|
|
||||||
.gte("created_at", startDateISO)
|
|
||||||
.order("created_at", { ascending: true });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// KPI
|
|
||||||
const total = data.length;
|
|
||||||
const delivered = data.filter(d => d.status === "delivered").length;
|
|
||||||
const inTransit = data.filter(d => d.status === "sent" || d.status === "checking").length;
|
|
||||||
const errors = data.filter(d => ["send_failed", "error", "limit_exceeded"].includes(d.status)).length;
|
|
||||||
const expired = data.filter(d => d.status === "expired").length;
|
|
||||||
const deliveryRate = total > 0 ? Math.round((delivered / total) * 100) : 0;
|
|
||||||
|
|
||||||
setStats({ total, delivered, inTransit, errors, expired, deliveryRate });
|
|
||||||
|
|
||||||
// Daily data
|
|
||||||
const byDay = {};
|
|
||||||
data.forEach(d => {
|
|
||||||
const day = new Date(d.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
|
||||||
if (!byDay[day]) byDay[day] = { date: day, sent: 0, delivered: 0, errors: 0 };
|
|
||||||
byDay[day].sent++;
|
|
||||||
if (d.status === "delivered") byDay[day].delivered++;
|
|
||||||
if (["send_failed", "error", "limit_exceeded"].includes(d.status)) byDay[day].errors++;
|
|
||||||
});
|
|
||||||
setDailyData(Object.values(byDay));
|
|
||||||
|
|
||||||
// Status pie
|
|
||||||
const byStatus = {};
|
|
||||||
data.forEach(d => {
|
|
||||||
byStatus[d.status] = (byStatus[d.status] || 0) + 1;
|
|
||||||
});
|
|
||||||
setStatusData(Object.entries(byStatus).map(([name, value]) => ({
|
|
||||||
name: STATUS_LABELS[name] || name,
|
|
||||||
value,
|
|
||||||
color: STATUS_COLORS[name] || "#94a3b8",
|
|
||||||
})));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Stats error:", e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [campaignType, period]);
|
|
||||||
|
|
||||||
useEffect(() => { loadStats(); }, [loadStats]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-5">
|
|
||||||
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка статистики…</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats || stats.total === 0) {
|
|
||||||
return (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">Нет данных для статистики</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* ── Period selector ──────────────────────────────────────────────── */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{[
|
|
||||||
{ key: "7d", label: "7 дней" },
|
|
||||||
{ key: "14d", label: "14 дней" },
|
|
||||||
{ key: "30d", label: "30 дней" },
|
|
||||||
{ key: "all", label: "Всё время" },
|
|
||||||
].map(p => (
|
|
||||||
<button
|
|
||||||
key={p.key}
|
|
||||||
onClick={() => setPeriod(p.key)}
|
|
||||||
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
|
||||||
period === p.key
|
|
||||||
? "bg-[var(--color-accent)] text-white"
|
|
||||||
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── KPI cards ────────────────────────────────────────────────────── */}
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
||||||
<KpiCard label="Всего" value={stats.total} color="var(--color-text)" />
|
|
||||||
<KpiCard label="Доставлено" value={stats.delivered} color="#22c55e" />
|
|
||||||
<KpiCard label="В пути" value={stats.inTransit} color="#3b82f6" />
|
|
||||||
<KpiCard label="Истёк срок" value={stats.expired} color="#eab308" />
|
|
||||||
<KpiCard label="Ошибки" value={stats.errors} color="#ef4444" />
|
|
||||||
<KpiCard label="Конверсия" value={`${stats.deliveryRate}%`} color="var(--color-accent)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Charts ───────────────────────────────────────────────────────── */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
||||||
{/* Daily trend */}
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📈 Отправки по дням</div>
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<BarChart data={dailyData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
|
||||||
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} allowDecimals={false} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: "var(--color-surface-strong)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
|
||||||
<Bar dataKey="sent" name="Отправлено" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="errors" name="Ошибки" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Status donut */}
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">🍩 По статусам</div>
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={statusData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={85}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{statusData.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: "var(--color-surface-strong)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Delivery rate trend ──────────────────────────────────────────── */}
|
|
||||||
{dailyData.length > 1 && (
|
|
||||||
<Panel className="p-4">
|
|
||||||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📊 Конверсия доставки по дням</div>
|
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
|
||||||
<LineChart
|
|
||||||
data={dailyData.map(d => ({
|
|
||||||
date: d.date,
|
|
||||||
rate: d.sent > 0 ? Math.round((d.delivered / d.sent) * 100) : 0,
|
|
||||||
}))}
|
|
||||||
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
|
||||||
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} domain={[0, 100]} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: "var(--color-surface-strong)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
formatter={(v) => [`${v}%`, "Конверсия"]}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="rate"
|
|
||||||
name="Конверсия %"
|
|
||||||
stroke="var(--color-accent)"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3, fill: "var(--color-accent)" }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
|
||||||
const KpiCard = ({ label, value, color }) => (
|
|
||||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
|
||||||
<div className="text-[10px] font-medium text-[var(--color-text-muted)]">{label}</div>
|
|
||||||
<div className="mt-1 text-lg font-bold" style={{ color }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
@ -1,18 +1,4 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import {
|
import {
|
||||||
filterOrderGroups,
|
filterOrderGroups,
|
||||||
getOrderGroupDisplayStatusLabel,
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
|
@ -21,227 +7,15 @@ import {
|
||||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonPage } from "../UI/Loading";
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
|
||||||
if (!d) return "";
|
|
||||||
const [y, m, day] = d.split("-");
|
|
||||||
if (!y || !m || !day) return d;
|
|
||||||
return `${day}.${m}.${y}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default priority: agreed first, manual_required second, then funnel
|
|
||||||
const DEFAULT_FUNNEL_ORDER = [
|
|
||||||
"delivery:agreed",
|
|
||||||
"status:manual_required",
|
|
||||||
"status:ready_for_notification",
|
|
||||||
"delivery:pending_confirmation",
|
|
||||||
"status:first_sms_sent",
|
|
||||||
"status:second_sms_sent",
|
|
||||||
"delivery:driver_assigned",
|
|
||||||
"delivery:loaded",
|
|
||||||
"delivery:on_route",
|
|
||||||
"delivery:delivered",
|
|
||||||
"delivery:picked_up",
|
|
||||||
"delivery:paid_storage",
|
|
||||||
"delivery:problem",
|
|
||||||
"delivery:cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
const STORAGE_KEY = "logistics-section-order";
|
|
||||||
const COLLAPSED_KEY = "logistics-section-collapsed";
|
|
||||||
|
|
||||||
// Load custom order from localStorage, merge with defaults
|
|
||||||
const loadCustomOrder = () => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCustomOrder = (order) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load collapsed sections from localStorage
|
|
||||||
const loadCollapsedSections = () => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(COLLAPSED_KEY);
|
|
||||||
if (!raw) return new Set();
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return new Set(Array.isArray(parsed) ? parsed : []);
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCollapsedSections = (collapsedSet) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
|
||||||
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
|
||||||
const MIN_W = "min-w-[1080px]";
|
|
||||||
|
|
||||||
const TableHeader = () => (
|
|
||||||
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Город</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Тип</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Статус</div>
|
|
||||||
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (group, onSelectSet) => (
|
|
||||||
<button
|
|
||||||
key={group.id}
|
|
||||||
type="button"
|
|
||||||
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
|
||||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 px-3 py-1.5">
|
|
||||||
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
{group.customerPhone || ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{group.city || group.customerAddress || "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
|
||||||
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
|
||||||
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.deliveryDate ? (
|
|
||||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--color-text-muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{formatDateTime(group.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sortable section wrapper
|
|
||||||
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: statusValue });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Section header — drag handle + collapse toggle */}
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
{/* Drag handle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
|
||||||
title="Перетащите для изменения порядка"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
|
||||||
<circle cx="9" cy="5" r="1.8" />
|
|
||||||
<circle cx="15" cy="5" r="1.8" />
|
|
||||||
<circle cx="9" cy="12" r="1.8" />
|
|
||||||
<circle cx="15" cy="12" r="1.8" />
|
|
||||||
<circle cx="9" cy="19" r="1.8" />
|
|
||||||
<circle cx="15" cy="19" r="1.8" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-sm font-semibold">{label}</h3>
|
|
||||||
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
|
||||||
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className={MIN_W}>
|
|
||||||
<TableHeader />
|
|
||||||
{groups.map((g) => renderRow(g, onSelectSet))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
|
||||||
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
|
||||||
const custom = loadCustomOrder();
|
|
||||||
return custom || [...DEFAULT_FUNNEL_ORDER];
|
|
||||||
});
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cities = React.useMemo(() => {
|
const cities = React.useMemo(() => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
|
|
@ -269,64 +43,47 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
return map;
|
return map;
|
||||||
}, [filteredGroups]);
|
}, [filteredGroups]);
|
||||||
|
|
||||||
|
const FUNNEL_ORDER = [
|
||||||
|
"status:ready_for_notification",
|
||||||
|
"delivery:pending_confirmation",
|
||||||
|
"status:manual_required",
|
||||||
|
"status:first_sms_sent",
|
||||||
|
"status:second_sms_sent",
|
||||||
|
"delivery:agreed",
|
||||||
|
"delivery:driver_assigned",
|
||||||
|
"delivery:loaded",
|
||||||
|
"delivery:on_route",
|
||||||
|
"delivery:delivered",
|
||||||
|
"delivery:paid_storage",
|
||||||
|
"delivery:problem",
|
||||||
|
"delivery:cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
const totalGroups = filteredGroups.length;
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
const COLS = "grid-cols-[minmax(140px,2fr)_minmax(80px,1fr)_minmax(100px,1.2fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(120px,1fr)]";
|
||||||
const sortedEntries = React.useMemo(() => {
|
|
||||||
const present = new Set(statusGroups.keys());
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
// First: statuses in custom order that are present
|
const TableHeader = () => (
|
||||||
for (const statusValue of sectionOrder) {
|
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]`}>
|
||||||
if (present.has(statusValue)) {
|
<div className="px-4 py-3 font-medium">Клиент</div>
|
||||||
const data = statusGroups.get(statusValue);
|
<div className="px-4 py-3 font-medium">Город</div>
|
||||||
result.push([statusValue, data]);
|
<div className="px-4 py-3 font-medium">Дата доставки</div>
|
||||||
}
|
<div className="px-4 py-3 font-medium">Водитель</div>
|
||||||
}
|
<div className="px-4 py-3 font-medium">Статус</div>
|
||||||
|
<div className="px-4 py-3 font-medium">Обновлён</div>
|
||||||
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
|
</div>
|
||||||
for (const [statusValue, data] of statusGroups.entries()) {
|
);
|
||||||
if (!sectionOrder.includes(statusValue)) {
|
|
||||||
result.push([statusValue, data]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [statusGroups, sectionOrder]);
|
|
||||||
|
|
||||||
const handleDragEnd = (event) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
setSectionOrder((prevOrder) => {
|
|
||||||
// Build the full order including any new statuses
|
|
||||||
const allIds = sortedEntries.map(([id]) => id);
|
|
||||||
const oldIndex = allIds.indexOf(active.id);
|
|
||||||
const newIndex = allIds.indexOf(over.id);
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return prevOrder;
|
|
||||||
|
|
||||||
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
|
|
||||||
|
|
||||||
// Merge: replace positions of known statuses, keep unknown at end
|
|
||||||
// Save the full new order so it persists
|
|
||||||
saveCustomOrder(newAllOrder);
|
|
||||||
return newAllOrder;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SkeletonPage panels={3} />;
|
return <SkeletonPage panels={3} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 fs-zone-table">
|
<div className="space-y-6">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
|
||||||
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{totalGroups} групп</Badge>
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -344,27 +101,23 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<div className="grid gap-4">
|
||||||
sensors={sensors}
|
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
||||||
collisionDetection={closestCenter}
|
const idxA = FUNNEL_ORDER.indexOf(a);
|
||||||
onDragEnd={handleDragEnd}
|
const idxB = FUNNEL_ORDER.indexOf(b);
|
||||||
>
|
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
||||||
<SortableContext
|
if (idxA === -1) return 1;
|
||||||
items={sortedEntries.map(([id]) => id)}
|
if (idxB === -1) return -1;
|
||||||
strategy={verticalListSortingStrategy}
|
return idxA - idxB;
|
||||||
>
|
}).map(([statusValue, { label, groups }]) => {
|
||||||
<div className="space-y-4">
|
|
||||||
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
|
||||||
const isCollapsed = collapsedSections.has(statusValue);
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableSection
|
<Panel key={statusValue} className="overflow-hidden p-0">
|
||||||
key={statusValue}
|
<Button
|
||||||
statusValue={statusValue}
|
variant="ghost"
|
||||||
label={label}
|
className="flex w-full items-center justify-between px-5 py-3 text-left"
|
||||||
groups={groups}
|
onClick={() => {
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onToggle={() => {
|
|
||||||
setCollapsedSections((prev) => {
|
setCollapsedSections((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(statusValue)) {
|
if (next.has(statusValue)) {
|
||||||
|
|
@ -372,17 +125,66 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
} else {
|
} else {
|
||||||
next.add(statusValue);
|
next.add(statusValue);
|
||||||
}
|
}
|
||||||
saveCollapsedSections(next);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onSelectSet={onSelectSet}
|
>
|
||||||
/>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{label}</h3>
|
||||||
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
||||||
|
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<TableHeader />
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Button
|
||||||
|
key={group.id}
|
||||||
|
variant="ghost"
|
||||||
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left`}
|
||||||
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-2.5">
|
||||||
|
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">{group.customerPhone || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5 text-sm">
|
||||||
|
{group.city || group.customerAddress || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5 text-sm">
|
||||||
|
{group.deliveryDate
|
||||||
|
? <span>{formatDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
: <span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5 text-sm">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(group.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export const OrdersTable = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-0 fs-zone-table">
|
<Panel className="p-0">
|
||||||
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -178,8 +178,8 @@ export const OrdersTable = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-[1080px]">
|
<div className="min-w-[920px]">
|
||||||
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
||||||
<div className="px-3 py-1.5 font-medium">Счета</div>
|
<div className="px-3 py-1.5 font-medium">Счета</div>
|
||||||
<div className="px-3 py-1.5 font-medium">Город</div>
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
|
|
@ -190,7 +190,7 @@ export const OrdersTable = ({
|
||||||
</div>
|
</div>
|
||||||
{orderGroups.map((group) => {
|
{orderGroups.map((group) => {
|
||||||
const hasProblem = group.hasDeliveryProblem;
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
|
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
|
||||||
hasProblem
|
hasProblem
|
||||||
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
||||||
: "hover:bg-[var(--color-accent-soft)]"
|
: "hover:bg-[var(--color-accent-soft)]"
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const FontSettingsContext = createContext(null);
|
|
||||||
|
|
||||||
const STORAGE_KEY = "supersam-font-settings";
|
|
||||||
|
|
||||||
// Categories: key → { label, defaultScale, min, max, step, description }
|
|
||||||
export const FONT_CATEGORIES = [
|
|
||||||
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
|
|
||||||
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
|
|
||||||
acc[cat.key] = cat.defaultScale;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
function loadSettings() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
// Merge with defaults to handle new categories
|
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_SETTINGS };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySettings(settings) {
|
|
||||||
const root = document.documentElement;
|
|
||||||
for (const cat of FONT_CATEGORIES) {
|
|
||||||
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FontSettingsProvider = ({ children }) => {
|
|
||||||
const [settings, setSettings] = useState(loadSettings);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applySettings(settings);
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const updateCategory = (key, scale) => {
|
|
||||||
setSettings((prev) => ({ ...prev, [key]: scale }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetAll = () => {
|
|
||||||
setSettings({ ...DEFAULT_SETTINGS });
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
settings,
|
|
||||||
updateCategory,
|
|
||||||
resetAll,
|
|
||||||
categories: FONT_CATEGORIES,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFontSettings = () => {
|
|
||||||
const context = useContext(FontSettingsContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useFontSettings must be used within FontSettingsProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { ROLE_LABELS } from "../constants/roles";
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
import { Badge } from "../components/UI/Badge";
|
import { Badge } from "../components/UI/Badge";
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
@ -48,7 +47,7 @@ export const AppShell = ({
|
||||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
|
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||||
Панель
|
Панель
|
||||||
|
|
@ -82,9 +81,6 @@ export const AppShell = ({
|
||||||
{isGuideOpen ? "К рабочей области" : "Справка"}
|
{isGuideOpen ? "К рабочей области" : "Справка"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
|
|
||||||
Настройки
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -92,9 +88,9 @@ export const AppShell = ({
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
|
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<Panel className="p-4 xl:hidden fs-zone-heading">
|
<Panel className="p-4 xl:hidden">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -121,9 +117,6 @@ export const AppShell = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
|
||||||
⚙
|
|
||||||
</Button>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
|
|
@ -134,7 +127,7 @@ export const AppShell = ({
|
||||||
|
|
||||||
{/* Mobile tab navigation — STICKY TOP */}
|
{/* Mobile tab navigation — STICKY TOP */}
|
||||||
{shouldShowMobileNav && (
|
{shouldShowMobileNav && (
|
||||||
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6 fs-zone-nav">
|
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6">
|
||||||
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -160,7 +153,7 @@ export const AppShell = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop header */}
|
{/* Desktop header */}
|
||||||
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
|
<Panel className="hidden p-4 md:p-5 xl:block">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -191,9 +184,6 @@ export const AppShell = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
|
||||||
⚙
|
|
||||||
</Button>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ import { RouterProvider } from "react-router-dom";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
import { FontSettingsProvider } from "./context/FontSettingsContext";
|
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { initErrorLogging } from "./utils/errorLogger";
|
import { initErrorLogging } from "./utils/errorLogger";
|
||||||
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "./styles/fontSettings.css";
|
|
||||||
|
|
||||||
registerPwaServiceWorker();
|
registerPwaServiceWorker();
|
||||||
initErrorLogging();
|
initErrorLogging();
|
||||||
|
|
@ -17,11 +15,9 @@ initErrorLogging();
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<FontSettingsProvider>
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</FontSettingsProvider>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|
@ -221,10 +221,7 @@ export const ClientDeliveryPage = () => {
|
||||||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||||
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
||||||
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
||||||
const referenceDate = React.useMemo(
|
const referenceDate = React.useMemo(() => new Date(), [token]);
|
||||||
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
|
||||||
[token, invitation?.smsSentAt],
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Navigate, useNavigate, useSearchParams, useLocation } from "react-route
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
import { isOrderGroupVisibleToDriver } from "../services/orderGroupViews";
|
import { isOrderGroupVisibleToDriver } from "../services/orderGroupViews";
|
||||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
|
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
||||||
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
||||||
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
||||||
|
|
@ -192,12 +192,25 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// All roles (logistician, admin, mega_admin, manager) — same grouped board
|
if (userRole === "logistician") {
|
||||||
|
if (activeSection === "orders") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
|
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────
|
// ── Layout ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
/**
|
|
||||||
* @file SettingsPage.jsx
|
|
||||||
* @description Page for user settings: font size controls for all UI categories.
|
|
||||||
* Settings persist to localStorage via FontSettingsContext.
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
|
|
||||||
import { Panel } from "../components/UI/Panel";
|
|
||||||
import { Button } from "../components/UI/Button";
|
|
||||||
|
|
||||||
const PRESETS = [
|
|
||||||
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
|
|
||||||
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
|
|
||||||
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
|
|
||||||
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Slider = ({ category, value, onChange }) => {
|
|
||||||
const pct = ((value - category.min) / (category.max - category.min)) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
|
|
||||||
{Math.round(value * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={category.min}
|
|
||||||
max={category.max}
|
|
||||||
step={category.step}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
|
||||||
className="fs-slider flex-1"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
|
|
||||||
}}
|
|
||||||
aria-label={category.label}
|
|
||||||
/>
|
|
||||||
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
|
|
||||||
{value.toFixed(2)}×
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
|
||||||
const { settings, updateCategory, resetAll, categories } = useFontSettings();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const applyPreset = (scales) => {
|
|
||||||
for (const [key, scale] of Object.entries(scales)) {
|
|
||||||
updateCategory(key, scale);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if current settings match a preset
|
|
||||||
const activePreset = PRESETS.findIndex((p) =>
|
|
||||||
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
|
|
||||||
Настройки интерфейса
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
|
||||||
← Назад
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Presets */}
|
|
||||||
<Panel className="p-5 space-y-3">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
Быстрые пресеты
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
{PRESETS.map((preset, i) => (
|
|
||||||
<button
|
|
||||||
key={preset.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => applyPreset(preset.scales)}
|
|
||||||
className={[
|
|
||||||
"rounded-[18px] border-2 px-4 py-3 text-center transition",
|
|
||||||
activePreset === i
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium">{preset.label}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* Font size sliders */}
|
|
||||||
<Panel className="p-5 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
||||||
Размеры шрифтов
|
|
||||||
</h2>
|
|
||||||
<Button size="sm" variant="ghost" onClick={resetAll}>
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Slider
|
|
||||||
key={cat.key}
|
|
||||||
category={cat}
|
|
||||||
value={settings[cat.key] ?? cat.defaultScale}
|
|
||||||
onChange={(v) => updateCategory(cat.key, v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
|
|
||||||
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
|
|
||||||
<div className="fs-zone-heading space-y-1">
|
|
||||||
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
|
|
||||||
Заголовок секции
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="fs-zone-nav flex gap-2">
|
|
||||||
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
|
|
||||||
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
|
||||||
Пункт меню
|
|
||||||
</span>
|
|
||||||
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
|
|
||||||
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
|
||||||
Вкладка
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[var(--color-surface)]">
|
|
||||||
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
|
|
||||||
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-t border-[var(--color-border)]">
|
|
||||||
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
|
|
||||||
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
|
|
||||||
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
|
|
||||||
Текст в карточке — описание доставки или заказа.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
|
|
||||||
Основной текст интерфейса.
|
|
||||||
</p>
|
|
||||||
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
|
|
||||||
мелкая подпись · временная отметка
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
|
||||||
Настройки сохраняются на этом устройстве
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SettingsPage;
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { GroupDetailPage } from "./pages/GroupDetailPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
|
||||||
import { useAuth } from "./context/AuthContext";
|
import { useAuth } from "./context/AuthContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -67,14 +66,6 @@ export const router = createBrowserRouter([
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "settings",
|
|
||||||
element: (
|
|
||||||
<RequireAuth>
|
|
||||||
<SettingsPage />
|
|
||||||
</RequireAuth>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFoundPage />,
|
element: <NotFoundPage />,
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
/* Font size scale variables — applied by FontSettingsContext */
|
|
||||||
:root {
|
|
||||||
--fs-scale-table: 1.0;
|
|
||||||
--fs-scale-card: 1.0;
|
|
||||||
--fs-scale-nav: 1.0;
|
|
||||||
--fs-scale-heading: 1.0;
|
|
||||||
--fs-scale-body: 1.0;
|
|
||||||
--fs-scale-small: 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slider styling */
|
|
||||||
.fs-slider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent);
|
|
||||||
border: 3px solid var(--color-surface-strong);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-slider::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-slider::-moz-range-thumb {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent);
|
|
||||||
border: 3px solid var(--color-surface-strong);
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Zone overrides ──────────────────────────────────────────────────────
|
|
||||||
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
|
||||||
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
|
||||||
This lets us scale fonts without patching every component.
|
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Table zone */
|
|
||||||
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
|
|
||||||
/* Arbitrary pixel sizes used in tables */
|
|
||||||
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
|
|
||||||
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
|
|
||||||
|
|
||||||
/* Card zone */
|
|
||||||
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
|
|
||||||
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
|
|
||||||
|
|
||||||
/* Nav zone */
|
|
||||||
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
|
||||||
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
|
|
||||||
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
|
||||||
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
|
|
||||||
|
|
||||||
/* Heading zone */
|
|
||||||
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
|
|
||||||
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
|
|
||||||
|
|
||||||
/* Small text zone */
|
|
||||||
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
|
||||||
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
|
||||||
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
|
|
||||||
|
|
||||||
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
|
|
||||||
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
|
||||||
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
|
||||||
Loading…
Reference in New Issue