feat: SmsCampaignPanel — SMS log + settings in mega_admin dashboard
- sms_campaign_log table: full audit trail (phone, sms_id, status, code, attempts) - sms_campaign_settings: editable by megaadmin (wait times, max attempts, delays) - New tab 'SMS-кампании' in mega_admin navigation - State machine: no blocking sleeps, fast cron runs - Protection: no duplicate SMS within 24h, code 231/132 = limit, not retry
This commit is contained in:
parent
174c74ec0b
commit
f264a53c0a
|
|
@ -1,31 +1,31 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
SuperSam — Первая отправка согласования доставки (SMS)
|
SuperSam — SMS First Campaign (State Machine)
|
||||||
Заменяет n8n workflow "Первая отправка согласования" (JnfZwOnpjEazseW5)
|
Заменяет n8n workflow "Первая отправка согласования"
|
||||||
|
|
||||||
Логика:
|
Архитектура: state machine через cron (каждые 5 мин)
|
||||||
1. SELECT order_groups WHERE status='ready_to_launch' AND delivery_status='pending_confirmation'
|
Каждый запуск:
|
||||||
2. Для каждой группы с delivery_link и notification_status='link_ready':
|
1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
|
||||||
a. Отправка SMS через sms.ru/sms/send
|
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||||||
b. Ждёт wait_between_checks_seconds (25 сек по умолчанию)
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
c. Проверка статуса через sms.ru/sms/status
|
|
||||||
d. Если код 103 (доставлено) → UPDATE order_groups + лог
|
Защита от повторной отправки:
|
||||||
e. Если код 102 (в пути) → повторная проверка каждые wait_between_checks
|
- Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes → skip
|
||||||
f. Если в течение max_check_duration_minutes (90 мин) не доставлено → повторная отправка SMS
|
- Код 231/132 = лимит одинаковых → не повторять
|
||||||
g. Если повторная отправка тоже не доставлена за max_check_duration → manual_required
|
|
||||||
3. Telegram-уведомление после каждой отправки
|
Коды sms.ru:
|
||||||
4. Полный лог в sms_campaign_log (видно в админке)
|
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||||
|
103 = доставлено → цель
|
||||||
|
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import requests
|
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
|
import requests
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
|
@ -46,6 +46,15 @@ SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||||||
|
|
||||||
LOG_FILE = "/var/log/supersam-sms-first.log"
|
LOG_FILE = "/var/log/supersam-sms-first.log"
|
||||||
|
|
||||||
|
# Коды, которые означают "в процессе" (ждём дальше)
|
||||||
|
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(
|
logging.basicConfig(
|
||||||
|
|
@ -67,7 +76,6 @@ def get_db_conn():
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_settings(conn):
|
def load_settings(conn):
|
||||||
"""Загружает настройки из sms_campaign_settings (управляются мегаадмином)"""
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
@ -78,24 +86,160 @@ def load_settings(conn):
|
||||||
"max_attempts": 2,
|
"max_attempts": 2,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"telegram_chat_id": "25164483",
|
"telegram_chat_id": "25164483",
|
||||||
|
"sms_api_id": SMS_API_ID,
|
||||||
}
|
}
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
def get_pending_groups(conn):
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
"""Получает группы для отправки первой SMS"""
|
|
||||||
|
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):
|
||||||
|
"""Проверяет статус, возвращает (code, raw_response)"""
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ─── 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:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, group_key, customer_name, customer_phone, customer_phone_normalized,
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
delivery_link, notification_status, status, delivery_status
|
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||||
FROM order_groups
|
FROM order_groups og
|
||||||
WHERE status = 'ready_to_launch'
|
WHERE og.status = 'ready_to_launch'
|
||||||
AND delivery_status = 'pending_confirmation'
|
AND og.delivery_status = 'pending_confirmation'
|
||||||
ORDER BY created_at ASC
|
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(row) for row in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены"""
|
||||||
|
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,
|
||||||
|
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'
|
||||||
|
ORDER BY 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():
|
||||||
|
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):
|
def update_order_group(conn, group_id, fields):
|
||||||
"""Обновляет order_groups"""
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
set_parts = []
|
set_parts = []
|
||||||
values = []
|
values = []
|
||||||
|
|
@ -109,289 +253,233 @@ def update_order_group(conn, group_id, fields):
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def insert_sms_log(conn, **kwargs):
|
|
||||||
"""Записывает лог в sms_campaign_log"""
|
|
||||||
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})", list(kwargs.values()))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def update_sms_log(conn, log_id, **kwargs):
|
|
||||||
"""Обновляет запись лога"""
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
set_parts = []
|
|
||||||
values = []
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
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()
|
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_sms(phone, message, api_id):
|
|
||||||
"""Отправляет SMS через sms.ru, возвращает (sms_id, raw_response)"""
|
|
||||||
try:
|
|
||||||
# Нормализуем телефон: убираем всё кроме цифр, добавляем 7
|
|
||||||
clean_phone = "".join(c for c in phone if c.isdigit())
|
|
||||||
if clean_phone.startswith("8"):
|
|
||||||
clean_phone = "7" + clean_phone[1:]
|
|
||||||
elif not clean_phone.startswith("7"):
|
|
||||||
clean_phone = "7" + clean_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 response: {text[:200]}")
|
|
||||||
|
|
||||||
# Парсим sms_id: sms.ru возвращает строки, вторая строка = sms_id
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
if len(lines) >= 2 and lines[0].strip() == "100":
|
|
||||||
sms_id = lines[1].strip()
|
|
||||||
return sms_id, text
|
|
||||||
elif lines and lines[0].strip() == "100":
|
|
||||||
sms_id = lines[1].strip() if len(lines) > 1 else ""
|
|
||||||
return sms_id, text
|
|
||||||
else:
|
|
||||||
return None, text
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS send error: {e}")
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
def check_sms_status(sms_id, api_id):
|
|
||||||
"""Проверяет статус SMS, возвращает код (102=в пути, 103=доставлено)"""
|
|
||||||
try:
|
|
||||||
resp = requests.post(SMS_STATUS_URL, params={
|
|
||||||
"api_id": api_id,
|
|
||||||
"sms_id": sms_id,
|
|
||||||
}, timeout=30)
|
|
||||||
text = resp.text
|
|
||||||
log.info(f"SMS status response for {sms_id}: {text[:200]}")
|
|
||||||
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
if len(lines) >= 2:
|
|
||||||
code = lines[1].strip()
|
|
||||||
return code, text
|
|
||||||
return None, text
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"SMS status check error: {e}")
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_telegram(message, chat_id):
|
|
||||||
"""Отправляет сообщение в Telegram"""
|
|
||||||
if not TELEGRAM_BOT_TOKEN:
|
|
||||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram notification")
|
|
||||||
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 send failed: {e}")
|
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def process_group(conn, group, settings):
|
def step_send_new(conn, settings):
|
||||||
"""Обрабатывает одну группу: отправка SMS → проверка доставки → UPDATE"""
|
"""Шаг 1: Отправка SMS новым группам"""
|
||||||
group_id = group["id"]
|
|
||||||
customer_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", "")
|
|
||||||
|
|
||||||
if not delivery_link:
|
|
||||||
log.warning(f"Group {group_id}: no delivery_link, skipping")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if group.get("notification_status") != "link_ready":
|
|
||||||
log.info(f"Group {group_id}: notification_status={group.get('notification_status')}, skipping")
|
|
||||||
return False
|
|
||||||
|
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
wait_sec = settings.get("wait_between_checks_seconds", 25)
|
|
||||||
max_duration_min = settings.get("max_check_duration_minutes", 90)
|
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
|
|
||||||
log.info(f"=== Group {group_id} ({customer_name}, phone={phone}) ===")
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||||
for attempt in range(1, max_attempts + 1):
|
|
||||||
log.info(f"Group {group_id}: attempt {attempt}/{max_attempts}")
|
sent_count = 0
|
||||||
|
for group in groups:
|
||||||
# Запись лога отправки
|
group_id = str(group["id"])
|
||||||
log_id = None
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
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("""
|
cur.execute("""
|
||||||
INSERT INTO sms_campaign_log
|
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
||||||
(campaign_type, order_group_id, customer_phone, sms_text, status, attempts, created_at)
|
WHERE order_group_id = %s AND campaign_type = 'first_sms'
|
||||||
VALUES ('first_sms', %s, %s, %s, 'sending', %s, NOW())
|
AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
RETURNING id
|
AND status IN ('sent', 'checking', 'delivered')
|
||||||
""", (str(group_id), phone, sms_text, attempt))
|
""", (group_id,))
|
||||||
log_id = cur.fetchone()["id"]
|
recent_count = cur.fetchone()[0]
|
||||||
conn.commit()
|
if recent_count > 0:
|
||||||
|
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||||
|
|
||||||
|
log.info(f"Sending SMS to {name} ({phone})")
|
||||||
|
sms_id, raw, code = send_sms(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,
|
||||||
|
)
|
||||||
|
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}")
|
||||||
|
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
|
||||||
|
|
||||||
# Отправка SMS
|
def step_check_status(conn, settings):
|
||||||
sms_id, raw = send_sms(phone, sms_text, api_id)
|
"""Шаг 2: Проверка статуса ранее отправленных SMS"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
if not sms_id:
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
error = raw[:500] if raw else "No sms_id in response"
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
log.error(f"Group {group_id}: SMS send failed: {error}")
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
update_sms_log(conn, log_id, status="send_failed", sms_code=None, error_message=error)
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
if attempt < max_attempts:
|
|
||||||
log.info(f"Group {group_id}: retrying in {wait_sec}s...")
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
time.sleep(wait_sec)
|
log.info(f"Step 2: {len(sms_list)} SMS to check status")
|
||||||
continue
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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})")
|
||||||
|
# Сбрасываем, чтобы step_send_new подхватил (но только если нет другой недавней)
|
||||||
|
# Удаляем лог, чтобы группа снова попала в get_groups_to_send
|
||||||
|
# Нет — лучше помечаем как expired, а step_send_new проверяет отсутствие активных
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
else:
|
else:
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "send_failed",
|
||||||
"last_sms_error": error[:200],
|
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
||||||
"status": "ready_to_launch", # остаётся для повторной попытки
|
|
||||||
})
|
})
|
||||||
send_telegram(f"❌ SMS не отправлена: {customer_name} ({phone})\nОшибка: {error[:200]}", tg_chat)
|
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
return False
|
|
||||||
|
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
|
||||||
|
|
||||||
# SMS отправлена успешно
|
def step_handle_expired(conn, settings):
|
||||||
update_sms_log(conn, log_id, status="sent", sms_id=sms_id, sms_code="100")
|
"""Шаг 3: Обработка SMS с истёкшим сроком проверки"""
|
||||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}")
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
# Цикл проверки доставки (до max_check_duration_minutes)
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
start_time = datetime.now(timezone.utc)
|
|
||||||
max_duration = timedelta(minutes=max_duration_min)
|
expired = get_sms_expired(conn, max_duration)
|
||||||
check_interval = wait_sec
|
log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
|
||||||
|
|
||||||
while datetime.now(timezone.utc) - start_time < max_duration:
|
for item in expired:
|
||||||
log.info(f"Group {group_id}: waiting {check_interval}s before status check...")
|
log_id = item["log_id"]
|
||||||
time.sleep(check_interval)
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
code, raw_status = check_sms_status(sms_id, api_id)
|
phone = item.get("customer_phone", "")
|
||||||
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
attempts = item.get("attempts", 1)
|
||||||
|
delivery_link = item.get("delivery_link", "")
|
||||||
if code == "103":
|
|
||||||
# Доставлено!
|
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
log.info(f"Group {group_id}: SMS delivered (code=103)!")
|
update_sms_log(conn, log_id, status="expired",
|
||||||
update_sms_log(conn, log_id, status="delivered", sms_code="103")
|
error_message=f"Not delivered in {max_duration} minutes")
|
||||||
|
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
if attempts < max_attempts:
|
||||||
update_order_group(conn, group_id, {
|
# Будет повторная отправка на следующем запуске (step_send_new подхватит)
|
||||||
"notification_status": "first_sms_sent",
|
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1})")
|
||||||
"sms_attempts": attempt,
|
|
||||||
"first_sms_sent_at": "NOW()",
|
|
||||||
"last_sms_error": None,
|
|
||||||
"sms_sent_at": "NOW()",
|
|
||||||
"next_notification_check_at": (datetime.now(timezone.utc) + timedelta(hours=settings.get("second_sms_delay_hours", 3))).isoformat(),
|
|
||||||
"status": "first_sms_sent",
|
|
||||||
})
|
|
||||||
|
|
||||||
send_telegram(
|
|
||||||
f"✅ SMS доставлена: {customer_name} ({phone})\nПопытка {attempt}\nsms_id: {sms_id}",
|
|
||||||
tg_chat,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif code == "102":
|
|
||||||
# В пути — продолжаем ждать
|
|
||||||
log.info(f"Group {group_id}: SMS in transit (code=102), continuing to check...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif code in ("104", "105", "106", "107", "108"):
|
|
||||||
# Ошибка доставки
|
|
||||||
error_msg = f"SMS error code: {code}"
|
|
||||||
log.error(f"Group {group_id}: {error_msg}")
|
|
||||||
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=error_msg)
|
|
||||||
# Прерываем внутренний цикл, переходим к следующей попытке
|
|
||||||
break
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Неизвестный код — продолжаем проверять
|
|
||||||
log.warning(f"Group {group_id}: unknown SMS code: {code}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Время проверки истекло или ошибка — повторная отправка
|
|
||||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() / 60
|
|
||||||
log.warning(f"Group {group_id}: check duration exceeded ({elapsed:.0f} min) or error, attempt {attempt}")
|
|
||||||
update_sms_log(conn, log_id, status="timeout", error_message=f"Not delivered in {max_duration_min} min")
|
|
||||||
|
|
||||||
if attempt < max_attempts:
|
|
||||||
log.info(f"Group {group_id}: will retry SMS send...")
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
# Все попытки исчерпаны
|
# Все попытки исчерпаны
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "manual_required",
|
||||||
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
||||||
"status": "ready_to_launch",
|
|
||||||
})
|
})
|
||||||
send_telegram(
|
send_telegram(
|
||||||
f"⚠️ SMS не доставлена после {max_attempts} попыток: {customer_name} ({phone})\nТребуется ручное управление",
|
f"🔧 Требуется ручное управление: {name} ({phone})\n"
|
||||||
|
f"SMS не доставлена после {max_attempts} попыток",
|
||||||
tg_chat,
|
tg_chat,
|
||||||
)
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
log.info("SMS First Campaign — START")
|
log.info("SMS First Campaign — START")
|
||||||
conn = get_db_conn()
|
conn = get_db_conn()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings(conn)
|
settings = load_settings(conn)
|
||||||
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||||
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||||
f"max_attempts={settings.get('max_attempts')}")
|
f"max_attempts={settings.get('max_attempts')}")
|
||||||
|
|
||||||
if not settings.get("enabled", True):
|
if not settings.get("enabled", True):
|
||||||
log.info("Campaign disabled, exiting")
|
log.info("Campaign disabled, exiting")
|
||||||
return
|
return
|
||||||
|
|
||||||
groups = get_pending_groups(conn)
|
# State machine — каждый шаг быстрый, без blocking
|
||||||
log.info(f"Found {len(groups)} groups with status=ready_to_launch, delivery_status=pending_confirmation")
|
sent = step_send_new(conn, settings)
|
||||||
|
delivered = step_check_status(conn, settings)
|
||||||
if not groups:
|
step_handle_expired(conn, settings)
|
||||||
log.info("No groups to process, exiting")
|
|
||||||
return
|
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
||||||
|
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
if sent > 0 or delivered > 0:
|
||||||
sent = 0
|
send_telegram(
|
||||||
delivered = 0
|
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||||||
failed = 0
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
for group in groups:
|
|
||||||
try:
|
except Exception as e:
|
||||||
result = process_group(conn, group, settings)
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
if result:
|
|
||||||
delivered += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
sent += 1
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Error processing group {group.get('id')}: {e}", exc_info=True)
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
# Сводка в Telegram
|
|
||||||
summary = (f"📊 <b>Первая отправка — сводка</b>\n\n"
|
|
||||||
f"Групп найдено: {len(groups)}\n"
|
|
||||||
f"Отправлено: {sent}\n"
|
|
||||||
f"Доставлено: {delivered}\n"
|
|
||||||
f"Ошибок: {failed}")
|
|
||||||
send_telegram(summary, tg_chat)
|
|
||||||
log.info(f"Summary: sent={sent}, delivered={delivered}, failed={failed}")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
log.info("SMS First Campaign — END")
|
log.info("SMS First Campaign — END")
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,361 @@
|
||||||
|
/**
|
||||||
|
* @file SmsCampaignPanel.jsx
|
||||||
|
* @description SMS Campaign log + settings for mega_admin.
|
||||||
|
* Shows sms_campaign_log entries with filtering and sms_campaign_settings editable.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
// ── Status labels ──────────────────────────────────────────────────────────
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
sent: "Отправлено",
|
||||||
|
checking: "Проверяется",
|
||||||
|
delivered: "Доставлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
error: "Ошибка доставки",
|
||||||
|
expired: "Истекло",
|
||||||
|
limit_exceeded: "Лимит превышен",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONES = {
|
||||||
|
sent: "info",
|
||||||
|
checking: "neutral",
|
||||||
|
delivered: "accent",
|
||||||
|
send_failed: "danger",
|
||||||
|
error: "danger",
|
||||||
|
expired: "warning",
|
||||||
|
limit_exceeded: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SMS code labels (from sms.ru docs) ────────────────────────────────────────
|
||||||
|
const SMS_CODE_LABELS = {
|
||||||
|
"100": "В очереди",
|
||||||
|
"101": "Оператору",
|
||||||
|
"102": "В пути",
|
||||||
|
"103": "Доставлено",
|
||||||
|
"104": "Истекло время",
|
||||||
|
"105": "Удалено оператором",
|
||||||
|
"106": "Сбой телефона",
|
||||||
|
"107": "Неизвестная причина",
|
||||||
|
"108": "Отклонено",
|
||||||
|
"130": "Лимит на номер/день",
|
||||||
|
"131": "Лимит одинаковых/мин",
|
||||||
|
"132": "Лимит одинаковых/день",
|
||||||
|
"200": "Неправильный api_id",
|
||||||
|
"201": "Недостаточно средств",
|
||||||
|
"202": "Неправильный получатель",
|
||||||
|
"230": "Общий лимит/день",
|
||||||
|
"231": "Лимит одинаковых/мин",
|
||||||
|
"232": "Лимит одинаковых/день",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtPhone = (phone) => {
|
||||||
|
if (!phone) return "—";
|
||||||
|
const digits = String(phone).replace(/\D/g, "");
|
||||||
|
if (digits.length === 11 && digits.startsWith("7")) {
|
||||||
|
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmsCampaignPanel = () => {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||||
|
|
||||||
|
// ── Load data ──────────────────────────────────────────────────────────────
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Load logs
|
||||||
|
let query = supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
|
||||||
|
if (filter !== "all") {
|
||||||
|
query = query.eq("status", filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: logData, error: logError } = await query;
|
||||||
|
if (logError) throw logError;
|
||||||
|
setLogs(logData || []);
|
||||||
|
|
||||||
|
// Load settings
|
||||||
|
const { data: settingsData, error: settingsError } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.select("*")
|
||||||
|
.eq("campaign_type", "first_sms")
|
||||||
|
.single();
|
||||||
|
if (settingsError && settingsError.code !== "PGRST116") throw settingsError;
|
||||||
|
setSettings(settingsData || null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// ── Save settings ──────────────────────────────────────────────────────────
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
const { id, ...updates } = settings;
|
||||||
|
updates.updated_at = new Date().toISOString();
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setSettingsSaved(true);
|
||||||
|
setTimeout(() => setSettingsSaved(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка сохранения: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = (key, value) => {
|
||||||
|
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stats summary ─────────────────────────────────────────────────────────
|
||||||
|
const stats = logs.reduce((acc, log) => {
|
||||||
|
acc[log.status] = (acc[log.status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Settings panel */}
|
||||||
|
{settings && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">Настройки кампании</h3>
|
||||||
|
{settingsSaved && (
|
||||||
|
<span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<SettingField
|
||||||
|
label="Пауза между проверками (сек)"
|
||||||
|
value={settings.wait_between_checks_seconds}
|
||||||
|
onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)}
|
||||||
|
/>
|
||||||
|
<SettingField
|
||||||
|
label="Макс. время ожидания (мин)"
|
||||||
|
value={settings.max_check_duration_minutes}
|
||||||
|
onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)}
|
||||||
|
/>
|
||||||
|
<SettingField
|
||||||
|
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 className="mt-4 flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled ?? true}
|
||||||
|
onChange={(e) => updateSetting("enabled", e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-[var(--color-border)]"
|
||||||
|
/>
|
||||||
|
Кампания активна
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
disabled={savingSettings}
|
||||||
|
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 ? "Сохранение…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats summary */}
|
||||||
|
<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 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">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[800px]">
|
||||||
|
{/* Header */}
|
||||||
|
<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">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>
|
||||||
|
{/* Rows */}
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Нет записей в логе
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.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)]"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
|
{fmtPhone(entry.customer_phone)}
|
||||||
|
</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>
|
||||||
|
</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 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)}
|
||||||
|
{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>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Refresh button */}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helper components ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SettingField = ({ label, value, onChange }) => (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FilterButton = ({ active, onClick, children }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
|
active
|
||||||
|
? "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)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
@ -16,6 +16,7 @@ import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
||||||
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||||
|
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
@ -34,6 +35,7 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
|
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||||
|
|
@ -174,6 +176,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
||||||
|
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue