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:
root 2026-06-23 08:39:24 +00:00
parent 174c74ec0b
commit f264a53c0a
3 changed files with 728 additions and 276 deletions

View File

@ -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)

View File

@ -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>
);

View File

@ -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") {