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,237 +253,199 @@ 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", "")
def step_send_new(conn, settings):
"""Шаг 1: Отправка SMS новым группам"""
api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3)
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send SMS")
sent_count = 0
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "")
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
delivery_link = group.get("delivery_link", "")
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)
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 этой группе сегодня (защита от повторной отправки)
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) as cnt FROM sms_campaign_log
WHERE order_group_id = %s AND campaign_type = 'first_sms'
AND created_at > NOW() - INTERVAL '24 hours'
AND status IN ('sent', 'checking', 'delivered')
""", (group_id,))
recent_count = cur.fetchone()[0]
if recent_count > 0:
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
log.info(f"=== Group {group_id} ({customer_name}, phone={phone}) ===")
log.info(f"Sending SMS to {name} ({phone})")
sms_id, raw, code = send_sms(phone, sms_text, api_id)
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:
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()
# Отправка 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
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],
"status": "ready_to_launch", # остаётся для повторной попытки
})
send_telegram(f"❌ SMS не отправлена: {customer_name} ({phone})\nОшибка: {error[:200]}", tg_chat)
return False
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
# 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}")
return sent_count
# Цикл проверки доставки (до max_check_duration_minutes)
start_time = datetime.now(timezone.utc)
max_duration = timedelta(minutes=max_duration_min)
check_interval = wait_sec
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)
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)
sms_list = get_sms_to_check(conn, max_duration)
log.info(f"Step 2: {len(sms_list)} SMS to check status")
code, raw_status = check_sms_status(sms_id, api_id)
update_sms_log(conn, log_id, status="checking", sms_code=code)
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)
if code == "103":
code, raw, api_code = check_sms_status(sms_id, api_id)
if code == DELIVERED_CODE:
# Доставлено!
log.info(f"Group {group_id}: SMS delivered (code=103)!")
update_sms_log(conn, log_id, status="delivered", sms_code="103")
log.info(f"Group {group_id}: SMS delivered (103)!")
update_sms_log(conn, log_id, status="delivered", sms_code=code)
now_iso = datetime.now(timezone.utc).isoformat()
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": attempt,
"sms_attempts": attempts,
"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(),
"next_notification_check_at": next_check,
"status": "first_sms_sent",
})
send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
delivered += 1
send_telegram(
f"✅ SMS доставлена: {customer_name} ({phone})\nПопытка {attempt}\nsms_id: {sms_id}",
tg_chat,
)
return True
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 == "102":
# В пути — продолжаем ждать
log.info(f"Group {group_id}: SMS in transit (code=102), continuing to check...")
continue
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": f"Delivery failed after {max_attempts} attempts (code={code})",
})
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
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
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}")
continue
update_sms_log(conn, log_id, status="checking", sms_code=code,
error_message=f"Unknown code: {code}")
# Время проверки истекло или ошибка — повторная отправка
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")
return delivered
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)
@ -356,39 +462,21 @@ def main():
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")
# State machine — каждый шаг быстрый, без blocking
sent = step_send_new(conn, settings)
delivered = step_check_status(conn, settings)
step_handle_expired(conn, settings)
if not groups:
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)
sent = 0
delivered = 0
failed = 0
if sent > 0 or delivered > 0:
send_telegram(
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
)
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}")
log.error(f"Fatal error: {e}", exc_info=True)
finally:
conn.close()

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