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
"""
SuperSam Первая отправка согласования доставки (SMS)
Заменяет n8n workflow "Первая отправка согласования" (JnfZwOnpjEazseW5)
SuperSam SMS First Campaign (State Machine)
Заменяет n8n workflow "Первая отправка согласования"
Логика:
1. SELECT order_groups WHERE status='ready_to_launch' AND delivery_status='pending_confirmation'
2. Для каждой группы с delivery_link и notification_status='link_ready':
a. Отправка SMS через sms.ru/sms/send
b. Ждёт wait_between_checks_seconds (25 сек по умолчанию)
c. Проверка статуса через sms.ru/sms/status
d. Если код 103 (доставлено) UPDATE order_groups + лог
e. Если код 102 (в пути) повторная проверка каждые wait_between_checks
f. Если в течение max_check_duration_minutes (90 мин) не доставлено повторная отправка SMS
g. Если повторная отправка тоже не доставлена за max_check_duration manual_required
3. Telegram-уведомление после каждой отправки
4. Полный лог в sms_campaign_log (видно в админке)
Архитектура: 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
Защита от повторной отправки:
- Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes skip
- Код 231/132 = лимит одинаковых не повторять
Коды sms.ru:
100 = в очереди, 101 = оператору, 102 = в пути ждём
103 = доставлено цель
104-108, 130-132, 230-232 = ошибки доставки / лимиты
"""
import os
import sys
import time
import json
import logging
import requests
from datetime import datetime, timezone, timedelta
from decimal import Decimal
import requests
import psycopg2
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"
# Коды, которые означают "в процессе" (ждём дальше)
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(
@ -67,7 +76,6 @@ def get_db_conn():
)
def load_settings(conn):
"""Загружает настройки из sms_campaign_settings (управляются мегаадмином)"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
row = cur.fetchone()
@ -78,24 +86,160 @@ def load_settings(conn):
"max_attempts": 2,
"enabled": True,
"telegram_chat_id": "25164483",
"sms_api_id": SMS_API_ID,
}
return dict(row)
def get_pending_groups(conn):
"""Получает группы для отправки первой SMS"""
# ─── 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):
"""Проверяет статус, возвращает (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:
cur.execute("""
SELECT id, group_key, customer_name, customer_phone, customer_phone_normalized,
delivery_link, notification_status, status, delivery_status
FROM order_groups
WHERE status = 'ready_to_launch'
AND delivery_status = 'pending_confirmation'
ORDER BY created_at ASC
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(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):
"""Обновляет order_groups"""
with conn.cursor() as cur:
set_parts = []
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)
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):
"""Обрабатывает одну группу: отправка SMS → проверка доставки → UPDATE"""
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
def step_send_new(conn, settings):
"""Шаг 1: Отправка SMS новым группам"""
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)
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
log.info(f"=== Group {group_id} ({customer_name}, phone={phone}) ===")
for attempt in range(1, max_attempts + 1):
log.info(f"Group {group_id}: attempt {attempt}/{max_attempts}")
# Запись лога отправки
log_id = None
with conn.cursor(cursor_factory=RealDictCursor) as cur:
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("""
INSERT INTO sms_campaign_log
(campaign_type, order_group_id, customer_phone, sms_text, status, attempts, created_at)
VALUES ('first_sms', %s, %s, %s, 'sending', %s, NOW())
RETURNING id
""", (str(group_id), phone, sms_text, attempt))
log_id = cur.fetchone()["id"]
conn.commit()
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}"
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
sms_id, raw = send_sms(phone, sms_text, api_id)
if not sms_id:
error = raw[:500] if raw else "No sms_id in response"
log.error(f"Group {group_id}: SMS send failed: {error}")
update_sms_log(conn, log_id, status="send_failed", sms_code=None, error_message=error)
if attempt < max_attempts:
log.info(f"Group {group_id}: retrying in {wait_sec}s...")
time.sleep(wait_sec)
continue
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)
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:
update_order_group(conn, group_id, {
"notification_status": "send_failed",
"last_sms_error": error[:200],
"status": "ready_to_launch", # остаётся для повторной попытки
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
})
send_telegram(f"❌ SMS не отправлена: {customer_name} ({phone})\nОшибка: {error[:200]}", tg_chat)
return False
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
# SMS отправлена успешно
update_sms_log(conn, log_id, status="sent", sms_id=sms_id, sms_code="100")
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}")
# Цикл проверки доставки (до max_check_duration_minutes)
start_time = datetime.now(timezone.utc)
max_duration = timedelta(minutes=max_duration_min)
check_interval = wait_sec
while datetime.now(timezone.utc) - start_time < max_duration:
log.info(f"Group {group_id}: waiting {check_interval}s before status check...")
time.sleep(check_interval)
code, raw_status = check_sms_status(sms_id, api_id)
update_sms_log(conn, log_id, status="checking", sms_code=code)
if code == "103":
# Доставлено!
log.info(f"Group {group_id}: SMS delivered (code=103)!")
update_sms_log(conn, log_id, status="delivered", sms_code="103")
now_iso = datetime.now(timezone.utc).isoformat()
update_order_group(conn, group_id, {
"notification_status": "first_sms_sent",
"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
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:
# Будет повторная отправка на следующем запуске (step_send_new подхватит)
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1})")
else:
# Все попытки исчерпаны
update_order_group(conn, group_id, {
"notification_status": "send_failed",
"notification_status": "manual_required",
"last_sms_error": f"Not delivered after {max_attempts} attempts",
"status": "ready_to_launch",
})
send_telegram(
f"⚠️ SMS не доставлена после {max_attempts} попыток: {customer_name} ({phone})\nТребуется ручное управление",
f"🔧 Требуется ручное управление: {name} ({phone})\n"
f"SMS не доставлена после {max_attempts} попыток",
tg_chat,
)
return False
return False
# ─── 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
groups = get_pending_groups(conn)
log.info(f"Found {len(groups)} groups with status=ready_to_launch, delivery_status=pending_confirmation")
if not groups:
log.info("No groups to process, exiting")
return
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
sent = 0
delivered = 0
failed = 0
for group in groups:
try:
result = process_group(conn, group, settings)
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}")
# State machine — каждый шаг быстрый, без blocking
sent = step_send_new(conn, settings)
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),
)
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)

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 { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
@ -34,6 +35,7 @@ const MEGA_ADMIN_NAV = [
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
];
// 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 === "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 === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
if (isLoading) {
if (userRole === "driver") {