diff --git a/package-lock.json b/package-lock.json
index 6f9e886..71768e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "construction-delivery",
"version": "0.1.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "2.52.0",
"clsx": "2.1.1",
"date-fns": "4.1.0",
@@ -339,6 +342,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
diff --git a/package.json b/package.json
index 228d050..fe269a8 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,9 @@
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "2.52.0",
"clsx": "2.1.1",
"date-fns": "4.1.0",
@@ -20,8 +23,8 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "7.3.0",
- "tailwind-merge": "3.3.0",
- "recharts": "^2.15.0"
+ "recharts": "^2.15.0",
+ "tailwind-merge": "3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
@@ -38,4 +41,4 @@
"vite": "^6.2.0",
"vitest": "^3.0.9"
}
-}
\ No newline at end of file
+}
diff --git a/public/service-worker.js b/public/service-worker.js
index 0f32750..5cc8e2c 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
- const STATIC_CACHE = "construction-delivery-static-v10";
- const RUNTIME_CACHE = "construction-delivery-runtime-v10";
+ const STATIC_CACHE = "construction-delivery-static-v11";
+ const RUNTIME_CACHE = "construction-delivery-runtime-v11";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => {
diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py
index 1afe396..129e473 100644
--- a/scripts/sms_first_campaign.py
+++ b/scripts/sms_first_campaign.py
@@ -36,7 +36,7 @@ from psycopg2.extras import RealDictCursor
# ─── Конфигурация ────────────────────────────────────────────────────────────
-DB_HOST = os.environ.get("DB_HOST", "10.0.4.5")
+DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
DB_PORT = os.environ.get("DB_PORT", "5432")
DB_NAME = os.environ.get("DB_NAME", "postgres")
DB_USER = os.environ.get("DB_USER", "supabase_admin")
@@ -416,10 +416,11 @@ def step_check_status(conn, settings):
# Переход к следующей попытке отправки (если есть)
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")
+ # Сбрасываем notification_status → link_ready для повторной отправки
+ update_order_group(conn, group_id, {
+ "notification_status": "link_ready",
+ })
else:
update_order_group(conn, group_id, {
"notification_status": "send_failed",
@@ -468,8 +469,11 @@ def step_handle_expired(conn, settings):
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})")
+ # Сбрасываем notification_status → link_ready для повторной отправки
+ update_order_group(conn, group_id, {
+ "notification_status": "link_ready",
+ })
+ log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
else:
# Все попытки исчерпаны
update_order_group(conn, group_id, {
diff --git a/scripts/sms_first_campaign_ref.py b/scripts/sms_first_campaign_ref.py
new file mode 100644
index 0000000..473f6da
--- /dev/null
+++ b/scripts/sms_first_campaign_ref.py
@@ -0,0 +1,583 @@
+#!/usr/bin/env python3
+"""
+SuperSam — SMS First Campaign (State Machine)
+Заменяет n8n workflow "Первая отправка согласования"
+
+Архитектура: state machine через cron (каждые 5 мин)
+Каждый запуск:
+ 1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
+ 2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
+ 3. Обновляет статусы в order_groups + sms_campaign_log
+
+Защита от повторной отправки (ДВОЙНАЯ):
+ 1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
+ → get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
+ 2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
+ 3. Код 231/132 = лимит одинаковых → не повторять
+
+needs_check=true — admin нажал "Проверить снова" в UI
+ → скрипт проверяет даже если запись старше max_check_duration
+
+Коды sms.ru:
+ 100 = в очереди, 101 = оператору, 102 = в пути → ждём
+ 103 = доставлено → цель
+ 104-108, 130-132, 230-232 = ошибки доставки / лимиты
+"""
+
+import os
+import sys
+import json
+import logging
+from datetime import datetime, timezone, timedelta
+
+import requests
+import psycopg2
+from psycopg2.extras import RealDictCursor
+
+# ─── Конфигурация ────────────────────────────────────────────────────────────
+
+DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
+DB_PORT = os.environ.get("DB_PORT", "5432")
+DB_NAME = os.environ.get("DB_NAME", "postgres")
+DB_USER = os.environ.get("DB_USER", "supabase_admin")
+DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
+
+TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
+
+SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
+SMS_SEND_URL = "https://sms.ru/sms/send"
+SMS_STATUS_URL = "https://sms.ru/sms/status"
+
+LOG_FILE = "/var/log/supersam-sms-first.log"
+
+# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
+# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
+# Мегаадмин выключает через админку → SMS идут реальным клиентам
+
+# Коды, которые означают "в процессе" (ждём дальше)
+IN_TRANSIT_CODES = {"100", "101", "102"}
+# Код доставки
+DELIVERED_CODE = "103"
+# Коды ошибок доставки (не временные, можно повторять отправку)
+DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
+# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
+LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
+
+# ─── Логирование ─────────────────────────────────────────────────────────────
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[
+ logging.FileHandler(LOG_FILE),
+ logging.StreamHandler(sys.stdout),
+ ],
+)
+log = logging.getLogger("sms_first")
+
+# ─── БД ──────────────────────────────────────────────────────────────────────
+
+def get_db_conn():
+ return psycopg2.connect(
+ host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
+ user=DB_USER, password=DB_PASS,
+ )
+
+def load_settings(conn):
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
+ row = cur.fetchone()
+ if not row:
+ return {
+ "wait_between_checks_seconds": 25,
+ "max_check_duration_minutes": 90,
+ "max_attempts": 2,
+ "enabled": True,
+ "telegram_chat_id": "25164483",
+ "sms_api_id": SMS_API_ID,
+ "test_mode": True,
+ "test_phone": "79788382260",
+ }
+ return dict(row)
+
+# ─── SMS API ─────────────────────────────────────────────────────────────────
+
+def normalize_phone(phone):
+ """Нормализует телефон: только цифры, начинается с 7"""
+ clean = "".join(c for c in str(phone) if c.isdigit())
+ if clean.startswith("8"):
+ clean = "7" + clean[1:]
+ elif not clean.startswith("7"):
+ clean = "7" + clean
+ return clean
+
+def send_sms(phone, message, api_id):
+ """Отправляет SMS, возвращает (sms_id, raw_response)"""
+ try:
+ clean_phone = normalize_phone(phone)
+ resp = requests.post(SMS_SEND_URL, params={
+ "api_id": api_id,
+ "to": clean_phone,
+ }, data={"msg": message}, timeout=30)
+ text = resp.text
+ log.info(f"SMS send to {clean_phone}: {text[:200]}")
+
+ lines = text.strip().split("\n")
+ status_code = lines[0].strip() if lines else ""
+
+ if status_code == "100" and len(lines) >= 2:
+ sms_id = lines[1].strip()
+ return sms_id, text, "100"
+ else:
+ return None, text, status_code
+ except Exception as e:
+ log.error(f"SMS send error: {e}")
+ return None, str(e), "error"
+
+def check_sms_status(sms_id, api_id):
+ """Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
+ try:
+ resp = requests.post(SMS_STATUS_URL, params={
+ "api_id": api_id,
+ "sms_id": sms_id,
+ }, timeout=30)
+ text = resp.text
+ lines = text.strip().split("\n")
+ status_code = lines[0].strip() if lines else ""
+ sms_status_code = lines[1].strip() if len(lines) >= 2 else None
+
+ log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
+ return sms_status_code, text, status_code
+ except Exception as e:
+ log.error(f"SMS status check error: {e}")
+ return None, str(e), "error"
+
+def fetch_balance(api_id):
+ """Получает баланс sms.ru, возвращает (balance_float, raw)"""
+ try:
+ resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
+ text = resp.text
+ lines = text.strip().split("\n")
+ if lines[0].strip() == "100" and len(lines) >= 2:
+ return float(lines[1].strip()), text
+ return None, text
+ except Exception as e:
+ log.error(f"Balance fetch error: {e}")
+ return None, str(e)
+
+# ─── Telegram ────────────────────────────────────────────────────────────────
+
+def send_telegram(message, chat_id):
+ if not TELEGRAM_BOT_TOKEN:
+ log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
+ return
+ try:
+ resp = requests.post(
+ f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
+ json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
+ timeout=10,
+ )
+ if resp.status_code != 200:
+ log.warning(f"Telegram error: {resp.text[:200]}")
+ except Exception as e:
+ log.warning(f"Telegram failed: {e}")
+
+# ─── State Machine ───────────────────────────────────────────────────────────
+
+def get_groups_to_send(conn):
+ """Группы, которым ещё не отправили SMS (или отправили очень давно)"""
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
+ og.customer_phone_normalized, og.delivery_link, og.notification_status
+ FROM order_groups og
+ WHERE og.status = 'ready_to_launch'
+ AND og.delivery_status = 'pending_confirmation'
+ AND og.delivery_link IS NOT NULL
+ AND og.delivery_link != ''
+ AND COALESCE(og.notification_status, '') = 'link_ready'
+ -- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_campaign_log scl
+ WHERE scl.order_group_id = og.id
+ AND scl.campaign_type = 'first_sms'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.created_at > NOW() - INTERVAL '2 hours'
+ )
+ ORDER BY og.created_at ASC
+ """)
+ return [dict(r) for r in cur.fetchall()]
+
+def get_sms_to_check(conn, max_duration_min):
+ """SMS в логе со status='sent'/'checking', которые ещё не доставлены.
+ Включает:
+ - Записи младше max_duration_min (обычная автопроверка)
+ - Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
+ """
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
+ scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
+ og.customer_name, og.group_key
+ FROM sms_campaign_log scl
+ JOIN order_groups og ON og.id = scl.order_group_id
+ WHERE scl.campaign_type = 'first_sms'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.sms_id IS NOT NULL
+ AND (
+ scl.created_at > NOW() - INTERVAL '%s minutes'
+ OR scl.needs_check = true
+ )
+ ORDER BY scl.needs_check DESC, scl.created_at ASC
+ """ % max_duration_min)
+ return [dict(r) for r in cur.fetchall()]
+
+def get_sms_expired(conn, max_duration_min):
+ """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
+ scl.attempts, scl.created_at,
+ og.customer_name, og.group_key, og.delivery_link
+ FROM sms_campaign_log scl
+ JOIN order_groups og ON og.id = scl.order_group_id
+ WHERE scl.campaign_type = 'first_sms'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.sms_id IS NOT NULL
+ AND scl.created_at < NOW() - INTERVAL '%s minutes'
+ ORDER BY scl.created_at ASC
+ """ % max_duration_min)
+ return [dict(r) for r in cur.fetchall()]
+
+def insert_sms_log(conn, **kwargs):
+ with conn.cursor() as cur:
+ cols = ", ".join(kwargs.keys())
+ placeholders = ", ".join(["%s"] * len(kwargs))
+ cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
+ log_id = cur.fetchone()[0]
+ conn.commit()
+ return log_id
+
+def update_sms_log(conn, log_id, **kwargs):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in kwargs.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(log_id)
+ cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
+ conn.commit()
+
+def update_order_group(conn, group_id, fields):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in fields.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(group_id)
+ cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
+ conn.commit()
+
+# ─── Проверка рабочего времени ────────────────────────────────────────────────
+
+def is_within_work_hours(settings):
+ """Проверка: сейчас рабочие часы.
+ settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
+ """
+ now_msk = datetime.now(timezone(timedelta(hours=3)))
+ today_num = now_msk.weekday() + 1
+ allowed_days = set()
+ work_days_str = settings.get("work_days", "1,2,3,4,5")
+ for part in str(work_days_str).split(","):
+ part = part.strip()
+ if part.isdigit():
+ allowed_days.add(int(part))
+ if today_num not in allowed_days:
+ return False
+ hour = now_msk.hour
+ start_h = settings.get("work_hours_start", 8)
+ end_h = settings.get("work_hours_end", 21)
+ return start_h <= hour < end_h
+
+# ─── Основная логика ─────────────────────────────────────────────────────────
+
+def step_send_new(conn, settings):
+ """Шаг 1: Отправка SMS новым группам"""
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+ max_attempts = settings.get("max_attempts", 2)
+ second_sms_delay = settings.get("second_sms_delay_hours", 3)
+
+ groups = get_groups_to_send(conn)
+ log.info(f"Step 1: {len(groups)} groups to send SMS")
+
+ sent_count = 0
+ for group in groups:
+ group_id = str(group["id"])
+ name = group.get("customer_name") or group.get("group_key", "—")
+ phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
+ delivery_link = group.get("delivery_link", "")
+
+ # Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT COUNT(*) as cnt FROM sms_campaign_log
+ WHERE order_group_id = %s AND campaign_type = 'first_sms'
+ AND created_at > NOW() - INTERVAL '24 hours'
+ AND status IN ('sent', 'checking', 'delivered')
+ """, (group_id,))
+ recent_count = cur.fetchone()[0]
+ if recent_count > 0:
+ log.info(f"Group {group_id}: already has recent SMS in log, skipping")
+ continue
+
+ sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
+
+ # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
+ send_phone = phone
+ if settings.get("test_mode", True):
+ send_phone = settings.get("test_phone", "79788382260")
+ log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
+
+ log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
+ sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
+
+ if sms_id:
+ log_id = insert_sms_log(conn,
+ campaign_type="first_sms",
+ order_group_id=group_id,
+ customer_phone=phone,
+ sms_id=sms_id,
+ sms_text=sms_text,
+ status="sent",
+ sms_code=code,
+ attempts=1,
+ )
+ # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
+ # чтобы get_groups_to_send не нашёл эту группу при следующем запуске
+ update_order_group(conn, group_id, {
+ "notification_status": "sms_sending",
+ "sms_sent_at": "NOW()",
+ })
+ log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
+ sent_count += 1
+ else:
+ # Ошибка отправки
+ error = raw[:500] if raw else "Unknown error"
+ log_id = insert_sms_log(conn,
+ campaign_type="first_sms",
+ order_group_id=group_id,
+ customer_phone=phone,
+ sms_text=sms_text,
+ status="send_failed",
+ sms_code=code,
+ attempts=1,
+ error_message=error,
+ )
+ log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
+
+ update_order_group(conn, group_id, {
+ "notification_status": "send_failed",
+ "last_sms_error": error[:200],
+ })
+ send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
+
+ return sent_count
+
+def step_check_status(conn, settings):
+ """Шаг 2: Проверка статуса ранее отправленных SMS"""
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+ max_duration = settings.get("max_check_duration_minutes", 90)
+ max_attempts = settings.get("max_attempts", 2)
+ second_sms_delay = settings.get("second_sms_delay_hours", 3)
+
+ sms_list = get_sms_to_check(conn, max_duration)
+ log.info(f"Step 2: {len(sms_list)} SMS to check status")
+
+ delivered = 0
+ for item in sms_list:
+ log_id = item["log_id"]
+ sms_id = item["sms_id"]
+ group_id = str(item["order_group_id"])
+ name = item.get("customer_name") or item.get("group_key", "—")
+ phone = item.get("customer_phone", "")
+ attempts = item.get("attempts", 1)
+ was_needs_check = item.get("needs_check", False)
+
+ code, raw, api_code = check_sms_status(sms_id, api_id)
+
+ # Сбрасываем needs_check + ставим checked_at
+ update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
+
+ if code == DELIVERED_CODE:
+ # Доставлено!
+ log.info(f"Group {group_id}: SMS delivered (103)!")
+ update_sms_log(conn, log_id, status="delivered", sms_code=code)
+
+ next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat()
+ update_order_group(conn, group_id, {
+ "notification_status": "first_sms_sent",
+ "sms_attempts": attempts,
+ "first_sms_sent_at": "NOW()",
+ "last_sms_error": None,
+ "sms_sent_at": "NOW()",
+ "next_notification_check_at": next_check,
+ "status": "first_sms_sent",
+ })
+ send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
+ delivered += 1
+
+ elif code in IN_TRANSIT_CODES:
+ # В пути / в очереди — продолжаем ждать
+ log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run")
+ update_sms_log(conn, log_id, status="checking", sms_code=code)
+
+ elif code in DELIVERY_ERROR_CODES:
+ # Ошибка доставки — можно повторить отправку
+ log.error(f"Group {group_id}: SMS delivery error (code={code})")
+ update_sms_log(conn, log_id, status="error", sms_code=code,
+ error_message=f"Delivery error: {code}")
+ # Переход к следующей попытке отправки (если есть)
+ if attempts < max_attempts:
+ log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
+ update_sms_log(conn, log_id, status="expired")
+ # Сбрасываем notification_status → link_ready для повторной отправки
+ update_order_group(conn, group_id, {
+ "notification_status": "link_ready",
+ })
+ else:
+ update_order_group(conn, group_id, {
+ "notification_status": "send_failed",
+ "last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
+ })
+ send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
+
+ elif code in LIMIT_ERROR_CODES:
+ # Превышен лимит — НЕ повторять отправку!
+ log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying")
+ update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
+ error_message=f"Limit exceeded: code={code}")
+ update_order_group(conn, group_id, {
+ "notification_status": "send_failed",
+ "last_sms_error": f"Limit exceeded (code={code}), sending blocked",
+ })
+ send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
+
+ else:
+ # Неизвестный код — логируем, продолжаем проверять
+ log.warning(f"Group {group_id}: unknown SMS code: {code}")
+ update_sms_log(conn, log_id, status="checking", sms_code=code,
+ error_message=f"Unknown code: {code}")
+
+ return delivered
+
+def step_handle_expired(conn, settings):
+ """Шаг 3: Обработка SMS с истёкшим сроком проверки"""
+ max_duration = settings.get("max_check_duration_minutes", 90)
+ max_attempts = settings.get("max_attempts", 2)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+
+ expired = get_sms_expired(conn, max_duration)
+ log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
+
+ for item in expired:
+ log_id = item["log_id"]
+ group_id = str(item["order_group_id"])
+ name = item.get("customer_name") or item.get("group_key", "—")
+ phone = item.get("customer_phone", "")
+ attempts = item.get("attempts", 1)
+ delivery_link = item.get("delivery_link", "")
+
+ log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
+ update_sms_log(conn, log_id, status="expired",
+ error_message=f"Not delivered in {max_duration} minutes")
+
+ if attempts < max_attempts:
+ # Сбрасываем notification_status → link_ready для повторной отправки
+ update_order_group(conn, group_id, {
+ "notification_status": "link_ready",
+ })
+ log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
+ else:
+ # Все попытки исчерпаны
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "last_sms_error": f"Not delivered after {max_attempts} attempts",
+ })
+ send_telegram(
+ f"🔧 Требуется ручное управление: {name} ({phone})\n"
+ f"SMS не доставлена после {max_attempts} попыток",
+ tg_chat,
+ )
+
+# ─── Main ────────────────────────────────────────────────────────────────────
+
+def main():
+ log.info("=" * 60)
+ log.info("SMS First Campaign — START")
+ conn = get_db_conn()
+
+ try:
+ settings = load_settings(conn)
+ log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
+ f"max_duration={settings.get('max_check_duration_minutes')}min, "
+ f"max_attempts={settings.get('max_attempts')}")
+
+ if not settings.get("enabled", True):
+ log.info("Campaign disabled, exiting")
+ return
+
+ # State machine — каждый шаг быстрый, без blocking
+ # Отправка только в рабочие часы
+ work_hours = is_within_work_hours(settings)
+ sent = 0
+ if work_hours:
+ sent = step_send_new(conn, settings)
+ else:
+ log.info("Outside work hours, skipping new SMS sends")
+
+ # Проверка статусов работает всегда
+ delivered = step_check_status(conn, settings)
+ step_handle_expired(conn, settings)
+
+ log.info(f"Run summary: sent={sent}, delivered={delivered}")
+
+ if sent > 0 or delivered > 0:
+ send_telegram(
+ f"📊 Первая отправка\nОтправлено: {sent}\nДоставлено: {delivered}",
+ settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
+ )
+
+ # Обновляем баланс sms.ru
+ if sent > 0:
+ balance, raw = fetch_balance(api_id)
+ if balance is not None:
+ with conn.cursor() as cur:
+ cur.execute(
+ "UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
+ (balance,)
+ )
+ conn.commit()
+ log.info(f"Balance updated: {balance} ₽")
+
+ except Exception as e:
+ log.error(f"Fatal error: {e}", exc_info=True)
+ finally:
+ conn.close()
+
+ log.info("SMS First Campaign — END")
+ log.info("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py
new file mode 100644
index 0000000..a35d142
--- /dev/null
+++ b/scripts/sms_manual_campaign.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""
+SuperSam — Manual Campaign (State Machine)
+Заменяет n8n workflow "Ручное управление"
+
+Логика:
+ Если после второй SMS (notification_status = 'second_sms_sent')
+ прошло auto_manual_after_hours и клиент не согласовал
+ (delivery_status = 'pending_confirmation'):
+ → notification_status = 'manual_required'
+ → delivery_status = 'manual_confirmation_required'
+ → next_notification_check_at = +3 мин
+ → Telegram-уведомление
+
+Расписание: 9-21, Пн-Сб (настраивается из админки)
+Скрипт НЕ отправляет SMS — только перевод в ручное управление.
+"""
+
+import os
+import sys
+import logging
+from datetime import datetime, timezone, timedelta
+
+import requests
+import psycopg2
+from psycopg2.extras import RealDictCursor
+
+# ─── Конфигурация ────────────────────────────────────────────────────────────
+
+DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
+DB_PORT = os.environ.get("DB_PORT", "5432")
+DB_NAME = os.environ.get("DB_NAME", "postgres")
+DB_USER = os.environ.get("DB_USER", "supabase_admin")
+DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
+
+TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
+
+LOG_FILE = "/var/log/supersam-sms-manual.log"
+
+CAMPAIGN_TYPE = "manual"
+
+# ─── Логирование ─────────────────────────────────────────────────────────────
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[
+ logging.FileHandler(LOG_FILE),
+ logging.StreamHandler(sys.stdout),
+ ],
+)
+log = logging.getLogger("sms_manual")
+
+# ─── БД ──────────────────────────────────────────────────────────────────────
+
+def get_db_conn():
+ return psycopg2.connect(
+ host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
+ user=DB_USER, password=DB_PASS,
+ )
+
+def load_settings(conn):
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
+ row = cur.fetchone()
+ if not row:
+ return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
+ return dict(row)
+
+# ─── Telegram ────────────────────────────────────────────────────────────────
+
+def send_telegram(message, chat_id):
+ if not TELEGRAM_BOT_TOKEN:
+ log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
+ return
+ try:
+ resp = requests.post(
+ f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
+ json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
+ timeout=10,
+ )
+ if resp.status_code != 200:
+ log.warning(f"Telegram error: {resp.text[:200]}")
+ except Exception as e:
+ log.warning(f"Telegram failed: {e}")
+
+# ─── Проверка рабочего времени ────────────────────────────────────────────────
+
+def is_within_work_hours(settings):
+ now_msk = datetime.now(timezone(timedelta(hours=3)))
+ today_num = now_msk.weekday() + 1
+ allowed_days = set()
+ work_days_str = settings.get("work_days", "1,2,3,4,5,6")
+ for part in str(work_days_str).split(","):
+ part = part.strip()
+ if part.isdigit():
+ allowed_days.add(int(part))
+ if today_num not in allowed_days:
+ return False
+ hour = now_msk.hour
+ start_h = settings.get("work_hours_start", 9)
+ end_h = settings.get("work_hours_end", 21)
+ return start_h <= hour < end_h
+
+# ─── State Machine ───────────────────────────────────────────────────────────
+
+def get_groups_to_manual(conn):
+ """Группы, где вторая SMS отправлена, но клиент не согласовал,
+ и пришло время перехода к ручному управлению.
+ """
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
+ og.notification_status, og.delivery_link,
+ og.second_sms_sent_at, og.next_notification_check_at
+ FROM order_groups og
+ WHERE og.delivery_status = 'pending_confirmation'
+ AND COALESCE(og.notification_status, '') = 'second_sms_sent'
+ AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
+ ORDER BY og.created_at ASC
+ """)
+ return [dict(r) for r in cur.fetchall()]
+
+def update_order_group(conn, group_id, fields):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in fields.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(group_id)
+ cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
+ conn.commit()
+
+# ─── Основная логика ─────────────────────────────────────────────────────────
+
+def step_move_to_manual(conn, settings):
+ """Перевод групп в ручное управление"""
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+ delay_minutes = settings.get("auto_manual_after_hours", 3)
+
+ groups = get_groups_to_manual(conn)
+ log.info(f"Step 1: {len(groups)} groups to move to manual")
+
+ moved = 0
+ for group in groups:
+ group_id = str(group["id"])
+ name = group.get("customer_name") or group.get("group_key", "—")
+ phone = group.get("customer_phone", "")
+ delivery_link = group.get("delivery_link", "")
+
+ log.info(f"Group {group_id}: moving to manual_required ({name})")
+
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "delivery_status": "manual_confirmation_required",
+ "sms_attempts": 2,
+ "last_sms_error": None,
+ "next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
+ "status": "manual_required",
+ })
+
+ send_telegram(
+ f"🔧 Ручное управление\n{name} ({phone})\n"
+ f"Клиент не согласовал доставку после двух SMS\n"
+ f"Ссылка: {delivery_link}",
+ tg_chat,
+ )
+ moved += 1
+
+ return moved
+
+# ─── Main ────────────────────────────────────────────────────────────────────
+
+def main():
+ log.info("=" * 60)
+ log.info("Manual Campaign — START")
+ conn = get_db_conn()
+
+ try:
+ settings = load_settings(conn)
+ log.info(f"Settings: work_hours={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
+ f"work_days={settings.get('work_days')}")
+
+ if not settings.get("enabled", True):
+ log.info("Campaign disabled, exiting")
+ return
+
+ # Только в рабочие часы
+ if not is_within_work_hours(settings):
+ log.info("Outside work hours, skipping")
+ return
+
+ moved = step_move_to_manual(conn, settings)
+
+ log.info(f"Run summary: moved_to_manual={moved}")
+
+ if moved > 0:
+ send_telegram(
+ f"📊 Ручное управление\nПереведено в ручное: {moved}",
+ settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
+ )
+
+ except Exception as e:
+ log.error(f"Fatal error: {e}", exc_info=True)
+ finally:
+ conn.close()
+
+ log.info("Manual Campaign — END")
+ log.info("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py
new file mode 100644
index 0000000..4abb2e9
--- /dev/null
+++ b/scripts/sms_paid_storage_campaign.py
@@ -0,0 +1,469 @@
+#!/usr/bin/env python3
+"""
+SuperSam — Paid Storage Campaign (State Machine)
+Уведомление о включении платного хранения.
+
+Логика:
+ Когда delivery_status = 'paid_storage' (логист/админ включил):
+ → Если рабочие часы (8-21, Пн-Пт): отправить SMS один раз
+ → Если ночью: ждать до утра
+ → Проверить доставку (103)
+ → Пометить notification_status = 'paid_storage_sent'
+
+SMS текст: "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день.
+ Заберите заказ или согласуйте доставку: {link}"
+
+Защита от повторной отправки:
+ 1. После отправки → notification_status = 'paid_storage_sending'
+ 2. EXISTS проверка в sms_campaign_log за 24h
+"""
+
+import os
+import sys
+import logging
+from datetime import datetime, timezone, timedelta
+
+import requests
+import psycopg2
+from psycopg2.extras import RealDictCursor
+
+# ─── Конфигурация ────────────────────────────────────────────────────────────
+
+DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
+DB_PORT = os.environ.get("DB_PORT", "5432")
+DB_NAME = os.environ.get("DB_NAME", "postgres")
+DB_USER = os.environ.get("DB_USER", "supabase_admin")
+DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
+
+TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
+
+SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
+SMS_SEND_URL = "https://sms.ru/sms/send"
+SMS_STATUS_URL = "https://sms.ru/sms/status"
+
+LOG_FILE = "/var/log/supersam-sms-paid-storage.log"
+
+CAMPAIGN_TYPE = "paid_storage"
+
+IN_TRANSIT_CODES = {"100", "101", "102"}
+DELIVERED_CODE = "103"
+DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
+LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
+
+# ─── Логирование ─────────────────────────────────────────────────────────────
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[
+ logging.FileHandler(LOG_FILE),
+ logging.StreamHandler(sys.stdout),
+ ],
+)
+log = logging.getLogger("sms_paid_storage")
+
+# ─── БД ──────────────────────────────────────────────────────────────────────
+
+def get_db_conn():
+ return psycopg2.connect(
+ host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
+ user=DB_USER, password=DB_PASS,
+ )
+
+def load_settings(conn):
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
+ row = cur.fetchone()
+ if not row:
+ return {
+ "enabled": True, "test_mode": True, "test_phone": "79788382260",
+ "max_check_duration_minutes": 90, "max_attempts": 2,
+ "telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
+ "work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
+ }
+ return dict(row)
+
+# ─── SMS API ─────────────────────────────────────────────────────────────────
+
+def normalize_phone(phone):
+ clean = "".join(c for c in str(phone) if c.isdigit())
+ if clean.startswith("8"):
+ clean = "7" + clean[1:]
+ elif not clean.startswith("7"):
+ clean = "7" + clean
+ return clean
+
+def send_sms(phone, message, api_id):
+ try:
+ clean_phone = normalize_phone(phone)
+ resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
+ data={"msg": message}, timeout=30)
+ text = resp.text
+ log.info(f"SMS send to {clean_phone}: {text[:200]}")
+ lines = text.strip().split("\n")
+ status_code = lines[0].strip() if lines else ""
+ if status_code == "100" and len(lines) >= 2:
+ return lines[1].strip(), text, "100"
+ return None, text, status_code
+ except Exception as e:
+ log.error(f"SMS send error: {e}")
+ return None, str(e), "error"
+
+def check_sms_status(sms_id, api_id):
+ try:
+ resp = requests.post(SMS_STATUS_URL, params={"api_id": api_id, "sms_id": sms_id}, timeout=30)
+ text = resp.text
+ lines = text.strip().split("\n")
+ status_code = lines[0].strip() if lines else ""
+ sms_status_code = lines[1].strip() if len(lines) >= 2 else None
+ log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
+ return sms_status_code, text, status_code
+ except Exception as e:
+ log.error(f"SMS status check error: {e}")
+ return None, str(e), "error"
+
+def fetch_balance(api_id):
+ try:
+ resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
+ text = resp.text
+ lines = text.strip().split("\n")
+ if lines[0].strip() == "100" and len(lines) >= 2:
+ return float(lines[1].strip()), text
+ return None, text
+ except Exception as e:
+ log.error(f"Balance fetch error: {e}")
+ return None, str(e)
+
+# ─── Telegram ────────────────────────────────────────────────────────────────
+
+def send_telegram(message, chat_id):
+ if not TELEGRAM_BOT_TOKEN:
+ log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
+ return
+ try:
+ resp = requests.post(
+ f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
+ json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
+ timeout=10,
+ )
+ if resp.status_code != 200:
+ log.warning(f"Telegram error: {resp.text[:200]}")
+ except Exception as e:
+ log.warning(f"Telegram failed: {e}")
+
+# ─── Проверка рабочего времени ────────────────────────────────────────────────
+
+def is_within_work_hours(settings):
+ now_msk = datetime.now(timezone(timedelta(hours=3)))
+ today_num = now_msk.weekday() + 1
+ allowed_days = set()
+ work_days_str = settings.get("work_days", "1,2,3,4,5")
+ for part in str(work_days_str).split(","):
+ part = part.strip()
+ if part.isdigit():
+ allowed_days.add(int(part))
+ if today_num not in allowed_days:
+ return False
+ hour = now_msk.hour
+ start_h = settings.get("work_hours_start", 8)
+ end_h = settings.get("work_hours_end", 21)
+ return start_h <= hour < end_h
+
+# ─── State Machine ───────────────────────────────────────────────────────────
+
+def get_groups_to_send(conn):
+ """Группы с paid_storage, которым ещё не отправили уведомление."""
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
+ og.customer_phone_normalized, og.delivery_link, og.notification_status,
+ og.paid_storage_at
+ FROM order_groups og
+ WHERE og.delivery_status = 'paid_storage'
+ AND og.paid_storage_at IS NOT NULL
+ AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
+ AND og.delivery_link IS NOT NULL
+ AND og.delivery_link != ''
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_campaign_log scl
+ WHERE scl.order_group_id = og.id
+ AND scl.campaign_type = 'paid_storage'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.created_at > NOW() - INTERVAL '24 hours'
+ )
+ ORDER BY og.paid_storage_at ASC
+ """)
+ return [dict(r) for r in cur.fetchall()]
+
+def get_sms_to_check(conn, max_duration_min):
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
+ scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
+ og.customer_name, og.group_key
+ FROM sms_campaign_log scl
+ JOIN order_groups og ON og.id = scl.order_group_id
+ WHERE scl.campaign_type = 'paid_storage'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.sms_id IS NOT NULL
+ AND (scl.created_at > NOW() - INTERVAL '%s minutes' OR scl.needs_check = true)
+ ORDER BY scl.needs_check DESC, scl.created_at ASC
+ """ % max_duration_min)
+ return [dict(r) for r in cur.fetchall()]
+
+def get_sms_expired(conn, max_duration_min):
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
+ scl.attempts, scl.created_at,
+ og.customer_name, og.group_key, og.delivery_link
+ FROM sms_campaign_log scl
+ JOIN order_groups og ON og.id = scl.order_group_id
+ WHERE scl.campaign_type = 'paid_storage'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.sms_id IS NOT NULL
+ AND scl.created_at < NOW() - INTERVAL '%s minutes'
+ ORDER BY scl.created_at ASC
+ """ % max_duration_min)
+ return [dict(r) for r in cur.fetchall()]
+
+def insert_sms_log(conn, **kwargs):
+ with conn.cursor() as cur:
+ cols = ", ".join(kwargs.keys())
+ placeholders = ", ".join(["%s"] * len(kwargs))
+ cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
+ log_id = cur.fetchone()[0]
+ conn.commit()
+ return log_id
+
+def update_sms_log(conn, log_id, **kwargs):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in kwargs.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(log_id)
+ cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
+ conn.commit()
+
+def update_order_group(conn, group_id, fields):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in fields.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(group_id)
+ cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
+ conn.commit()
+
+# ─── Основная логика ─────────────────────────────────────────────────────────
+
+def step_send_new(conn, settings):
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+
+ groups = get_groups_to_send(conn)
+ log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
+
+ sent_count = 0
+ for group in groups:
+ group_id = str(group["id"])
+ name = group.get("customer_name") or group.get("group_key", "—")
+ phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
+ delivery_link = group.get("delivery_link", "")
+
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT COUNT(*) FROM sms_campaign_log
+ WHERE order_group_id = %s AND campaign_type = 'paid_storage'
+ AND created_at > NOW() - INTERVAL '24 hours'
+ AND status IN ('sent', 'checking', 'delivered')
+ """, (group_id,))
+ if cur.fetchone()[0] > 0:
+ log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
+ continue
+
+ sms_text = (
+ f"Ваш заказ переведён на платное хранение. "
+ f"Стоимость: 300 ₽/день. "
+ f"Заберите заказ или согласуйте доставку: {delivery_link}"
+ )
+
+ send_phone = phone
+ if settings.get("test_mode", True):
+ send_phone = settings.get("test_phone", "79788382260")
+ log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
+
+ log.info(f"Sending paid_storage SMS to {name} (orig={phone}, send={send_phone})")
+ sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
+
+ if sms_id:
+ log_id = insert_sms_log(conn,
+ campaign_type=CAMPAIGN_TYPE,
+ order_group_id=group_id,
+ customer_phone=phone,
+ sms_id=sms_id,
+ sms_text=sms_text,
+ status="sent",
+ sms_code=code,
+ attempts=1,
+ )
+ update_order_group(conn, group_id, {
+ "notification_status": "paid_storage_sending",
+ "sms_sent_at": "NOW()",
+ })
+ log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
+ sent_count += 1
+ else:
+ error = raw[:500] if raw else "Unknown error"
+ insert_sms_log(conn,
+ campaign_type=CAMPAIGN_TYPE,
+ order_group_id=group_id,
+ customer_phone=phone,
+ sms_text=sms_text,
+ status="send_failed",
+ sms_code=code,
+ attempts=1,
+ error_message=error,
+ )
+ log.error(f"Group {group_id}: paid_storage SMS failed (code={code}): {error[:200]}")
+ update_order_group(conn, group_id, {"last_sms_error": error[:200]})
+ send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
+
+ return sent_count
+
+def step_check_status(conn, settings):
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+ max_duration = settings.get("max_check_duration_minutes", 90)
+
+ sms_list = get_sms_to_check(conn, max_duration)
+ log.info(f"Step 2: {len(sms_list)} paid_storage SMS to check")
+
+ delivered = 0
+ for item in sms_list:
+ log_id = item["log_id"]
+ sms_id = item["sms_id"]
+ group_id = str(item["order_group_id"])
+ name = item.get("customer_name") or item.get("group_key", "—")
+ phone = item.get("customer_phone", "")
+
+ code, raw, api_code = check_sms_status(sms_id, api_id)
+ update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
+
+ if code == DELIVERED_CODE:
+ log.info(f"Group {group_id}: paid_storage SMS delivered (103)!")
+ update_sms_log(conn, log_id, status="delivered", sms_code=code)
+ update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
+ send_telegram(f"✅ SMS платное хранение доставлена: {name} ({phone})", tg_chat)
+ delivered += 1
+ elif code in IN_TRANSIT_CODES:
+ log.info(f"Group {group_id}: in transit (code={code})")
+ update_sms_log(conn, log_id, status="checking", sms_code=code)
+ elif code in DELIVERY_ERROR_CODES:
+ log.error(f"Group {group_id}: delivery error (code={code})")
+ update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
+ update_sms_log(conn, log_id, status="expired")
+ # Сброс для retry
+ update_order_group(conn, group_id, {"notification_status": "manual_required"})
+ elif code in LIMIT_ERROR_CODES:
+ log.error(f"Group {group_id}: limit exceeded (code={code})")
+ update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}")
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "last_sms_error": f"Limit exceeded (code={code})",
+ })
+ send_telegram(f"🚫 SMS платное хранение заблокирована (лимит): {name} ({phone})", tg_chat)
+ else:
+ log.warning(f"Group {group_id}: unknown code: {code}")
+ update_sms_log(conn, log_id, status="checking", sms_code=code, error_message=f"Unknown: {code}")
+
+ return delivered
+
+def step_handle_expired(conn, settings):
+ max_duration = settings.get("max_check_duration_minutes", 90)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+
+ expired = get_sms_expired(conn, max_duration)
+ log.info(f"Step 3: {len(expired)} paid_storage SMS expired")
+
+ for item in expired:
+ log_id = item["log_id"]
+ group_id = str(item["order_group_id"])
+ name = item.get("customer_name") or item.get("group_key", "—")
+ phone = item.get("customer_phone", "")
+
+ log.warning(f"Group {group_id}: paid_storage SMS expired")
+ update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
+ update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
+ send_telegram(f"⚠️ SMS платное хранение не доставлена: {name} ({phone})", tg_chat)
+
+# ─── Main ────────────────────────────────────────────────────────────────────
+
+def main():
+ log.info("=" * 60)
+ log.info("Paid Storage Campaign — START")
+ conn = get_db_conn()
+
+ try:
+ settings = load_settings(conn)
+ log.info(f"Settings: work={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
+ f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
+
+ if not settings.get("enabled", True):
+ log.info("Campaign disabled, exiting")
+ return
+
+ # Отправка только в рабочие часы
+ work_hours = is_within_work_hours(settings)
+ sent = 0
+ if work_hours:
+ sent = step_send_new(conn, settings)
+ else:
+ log.info("Outside work hours, skipping new SMS sends")
+
+ # Проверка статусов — всегда
+ delivered = step_check_status(conn, settings)
+ step_handle_expired(conn, settings)
+
+ log.info(f"Run summary: sent={sent}, delivered={delivered}")
+
+ if sent > 0 or delivered > 0:
+ send_telegram(
+ f"📦 Платное хранение\nОтправлено: {sent}\nДоставлено: {delivered}",
+ settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
+ )
+
+ # Обновляем баланс
+ if sent > 0:
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ balance, raw = fetch_balance(api_id)
+ if balance is not None:
+ with conn.cursor() as cur:
+ cur.execute(
+ "UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'paid_storage'",
+ (balance,)
+ )
+ conn.commit()
+ log.info(f"Balance updated: {balance} ₽")
+
+ except Exception as e:
+ log.error(f"Fatal error: {e}", exc_info=True)
+ finally:
+ conn.close()
+
+ log.info("Paid Storage Campaign — END")
+ log.info("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py
new file mode 100644
index 0000000..5d61ef3
--- /dev/null
+++ b/scripts/sms_second_campaign.py
@@ -0,0 +1,586 @@
+#!/usr/bin/env python3
+"""
+SuperSam — SMS Second Campaign (State Machine)
+Заменяет n8n workflow "Первая проверка согласования + отправка 2й смс"
+
+Архитектура: state machine через cron (каждые 5 мин)
+Каждый запуск:
+ 1. Отправляет вторую SMS группам, где первая SMS доставлена,
+ но клиент не согласовал доставку, и пришло время next_notification_check_at
+ 2. Проверяет статус ранее отправленных вторых SMS
+ 3. Обновляет статусы в order_groups + sms_campaign_log
+
+Защита от повторной отправки (ДВОЙНАЯ):
+ 1. После отправки SMS → notification_status = 'second_sms_sending' (не 'first_sms_sent')
+ → get_groups_to_send НЕ находит эту группу
+ 2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
+ 3. Код 231/132 = лимит одинаковых → не повторять
+
+needs_check=true — admin нажал "Проверить снова" в UI
+ → скрипт проверяет даже если запись старше max_check_duration
+
+Коды sms.ru:
+ 100 = в очереди, 101 = оператору, 102 = в пути → ждём
+ 103 = доставлено → цель
+ 104-108, 130-132, 230-232 = ошибки доставки / лимиты
+"""
+
+import os
+import sys
+import json
+import logging
+from datetime import datetime, timezone, timedelta
+
+import requests
+import psycopg2
+from psycopg2.extras import RealDictCursor
+
+# ─── Конфигурация ────────────────────────────────────────────────────────────
+
+DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
+DB_PORT = os.environ.get("DB_PORT", "5432")
+DB_NAME = os.environ.get("DB_NAME", "postgres")
+DB_USER = os.environ.get("DB_USER", "supabase_admin")
+DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
+
+TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
+
+SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
+SMS_SEND_URL = "https://sms.ru/sms/send"
+SMS_STATUS_URL = "https://sms.ru/sms/status"
+
+LOG_FILE = "/var/log/supersam-sms-second.log"
+
+CAMPAIGN_TYPE = "second_sms"
+
+# Коды, которые означают "в процессе" (ждём дальше)
+IN_TRANSIT_CODES = {"100", "101", "102"}
+# Код доставки
+DELIVERED_CODE = "103"
+# Коды ошибок доставки (не временные, можно повторять отправку)
+DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
+# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
+LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
+
+# ─── Логирование ─────────────────────────────────────────────────────────────
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[
+ logging.FileHandler(LOG_FILE),
+ logging.StreamHandler(sys.stdout),
+ ],
+)
+log = logging.getLogger("sms_second")
+
+# ─── БД ──────────────────────────────────────────────────────────────────────
+
+def get_db_conn():
+ return psycopg2.connect(
+ host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
+ user=DB_USER, password=DB_PASS,
+ )
+
+def load_settings(conn):
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
+ row = cur.fetchone()
+ if not row:
+ # Fallback на настройки first_sms если second_sms нет
+ cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
+ row = cur.fetchone()
+ if not row:
+ return {
+ "wait_between_checks_seconds": 25,
+ "max_check_duration_minutes": 90,
+ "max_attempts": 2,
+ "enabled": True,
+ "telegram_chat_id": "25164483",
+ "sms_api_id": SMS_API_ID,
+ "test_mode": True,
+ "test_phone": "79788382260",
+ "second_sms_delay_hours": 3,
+ "auto_manual_after_hours": 3,
+ }
+ return dict(row)
+
+# ─── SMS API ─────────────────────────────────────────────────────────────────
+
+def normalize_phone(phone):
+ """Нормализует телефон: только цифры, начинается с 7"""
+ clean = "".join(c for c in str(phone) if c.isdigit())
+ if clean.startswith("8"):
+ clean = "7" + clean[1:]
+ elif not clean.startswith("7"):
+ clean = "7" + clean
+ return clean
+
+def send_sms(phone, message, api_id):
+ """Отправляет SMS, возвращает (sms_id, raw_response, code)"""
+ try:
+ clean_phone = normalize_phone(phone)
+ resp = requests.post(SMS_SEND_URL, params={
+ "api_id": api_id,
+ "to": clean_phone,
+ }, data={"msg": message}, timeout=30)
+ text = resp.text
+ log.info(f"SMS send to {clean_phone}: {text[:200]}")
+
+ lines = text.strip().split("\n")
+ status_code = lines[0].strip() if lines else ""
+
+ if status_code == "100" and len(lines) >= 2:
+ sms_id = lines[1].strip()
+ return sms_id, text, "100"
+ else:
+ return None, text, status_code
+ except Exception as e:
+ log.error(f"SMS send error: {e}")
+ return None, str(e), "error"
+
+def check_sms_status(sms_id, api_id):
+ """Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
+ try:
+ resp = requests.post(SMS_STATUS_URL, params={
+ "api_id": api_id,
+ "sms_id": sms_id,
+ }, timeout=30)
+ text = resp.text
+ lines = text.strip().split("\n")
+ status_code = lines[0].strip() if lines else ""
+ sms_status_code = lines[1].strip() if len(lines) >= 2 else None
+
+ log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
+ return sms_status_code, text, status_code
+ except Exception as e:
+ log.error(f"SMS status check error: {e}")
+ return None, str(e), "error"
+
+def fetch_balance(api_id):
+ """Получает баланс sms.ru, возвращает (balance_float, raw)"""
+ try:
+ resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
+ text = resp.text
+ lines = text.strip().split("\n")
+ if lines[0].strip() == "100" and len(lines) >= 2:
+ return float(lines[1].strip()), text
+ return None, text
+ except Exception as e:
+ log.error(f"Balance fetch error: {e}")
+ return None, str(e)
+
+# ─── Telegram ────────────────────────────────────────────────────────────────
+
+def send_telegram(message, chat_id):
+ if not TELEGRAM_BOT_TOKEN:
+ log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
+ return
+ try:
+ resp = requests.post(
+ f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
+ json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
+ timeout=10,
+ )
+ if resp.status_code != 200:
+ log.warning(f"Telegram error: {resp.text[:200]}")
+ except Exception as e:
+ log.warning(f"Telegram failed: {e}")
+
+# ─── State Machine ───────────────────────────────────────────────────────────
+
+def get_groups_to_send(conn):
+ """Группы, где первая SMS доставлена, но клиент не согласовал,
+ и пришло время для второй SMS (next_notification_check_at <= NOW()).
+ second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
+ """
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
+ og.customer_phone_normalized, og.delivery_link, og.notification_status
+ FROM order_groups og
+ WHERE og.delivery_status = 'pending_confirmation'
+ AND og.delivery_link IS NOT NULL
+ AND og.delivery_link != ''
+ AND COALESCE(og.notification_status, '') = 'first_sms_sent'
+ AND og.second_sms_sent_at IS NULL
+ AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
+ -- Нет активной второй SMS в логе
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_campaign_log scl
+ WHERE scl.order_group_id = og.id
+ AND scl.campaign_type = 'second_sms'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.created_at > NOW() - INTERVAL '2 hours'
+ )
+ ORDER BY og.created_at ASC
+ """)
+ return [dict(r) for r in cur.fetchall()]
+
+def get_sms_to_check(conn, max_duration_min):
+ """Вторые SMS в логе со status='sent'/'checking', которые ещё не доставлены.
+ Включает:
+ - Записи младше max_duration_min (обычная автопроверка)
+ - Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
+ """
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
+ scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
+ og.customer_name, og.group_key
+ FROM sms_campaign_log scl
+ JOIN order_groups og ON og.id = scl.order_group_id
+ WHERE scl.campaign_type = 'second_sms'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.sms_id IS NOT NULL
+ AND (
+ scl.created_at > NOW() - INTERVAL '%s minutes'
+ OR scl.needs_check = true
+ )
+ ORDER BY scl.needs_check DESC, scl.created_at ASC
+ """ % max_duration_min)
+ return [dict(r) for r in cur.fetchall()]
+
+def get_sms_expired(conn, max_duration_min):
+ """Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
+ scl.attempts, scl.created_at,
+ og.customer_name, og.group_key, og.delivery_link
+ FROM sms_campaign_log scl
+ JOIN order_groups og ON og.id = scl.order_group_id
+ WHERE scl.campaign_type = 'second_sms'
+ AND scl.status IN ('sent', 'checking')
+ AND scl.sms_id IS NOT NULL
+ AND scl.created_at < NOW() - INTERVAL '%s minutes'
+ ORDER BY scl.created_at ASC
+ """ % max_duration_min)
+ return [dict(r) for r in cur.fetchall()]
+
+def insert_sms_log(conn, **kwargs):
+ with conn.cursor() as cur:
+ cols = ", ".join(kwargs.keys())
+ placeholders = ", ".join(["%s"] * len(kwargs))
+ cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
+ log_id = cur.fetchone()[0]
+ conn.commit()
+ return log_id
+
+def update_sms_log(conn, log_id, **kwargs):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in kwargs.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(log_id)
+ cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
+ conn.commit()
+
+def update_order_group(conn, group_id, fields):
+ with conn.cursor() as cur:
+ set_parts = []
+ values = []
+ for k, v in fields.items():
+ if v == "NOW()":
+ set_parts.append(f"{k} = NOW()")
+ else:
+ set_parts.append(f"{k} = %s")
+ values.append(v)
+ values.append(group_id)
+ cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
+ conn.commit()
+
+# ─── Проверка рабочего времени ────────────────────────────────────────────────
+
+def is_within_work_hours(settings):
+ """Проверка: сейчас рабочие часы.
+ settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
+ """
+ now_msk = datetime.now(timezone(timedelta(hours=3)))
+ # work_days: '1,2,3,4,5' → Понедельник=1 ... Воскресенье=7
+ # Python weekday(): 0=Пн ... 6=Вс → конвертируем в 1-7
+ today_num = now_msk.weekday() + 1
+ allowed_days = set()
+ work_days_str = settings.get("work_days", "1,2,3,4,5")
+ for part in str(work_days_str).split(","):
+ part = part.strip()
+ if part.isdigit():
+ allowed_days.add(int(part))
+ if today_num not in allowed_days:
+ return False
+ hour = now_msk.hour
+ start_h = settings.get("work_hours_start", 8)
+ end_h = settings.get("work_hours_end", 21)
+ return start_h <= hour < end_h
+
+# ─── Основная логика ─────────────────────────────────────────────────────────
+
+def step_send_new(conn, settings):
+ """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+
+ groups = get_groups_to_send(conn)
+ log.info(f"Step 1: {len(groups)} groups to send second SMS")
+
+ sent_count = 0
+ for group in groups:
+ group_id = str(group["id"])
+ name = group.get("customer_name") or group.get("group_key", "—")
+ phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
+ delivery_link = group.get("delivery_link", "")
+
+ # Защита: была ли уже вторая SMS этой группе за 24h
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT COUNT(*) as cnt FROM sms_campaign_log
+ WHERE order_group_id = %s AND campaign_type = 'second_sms'
+ AND created_at > NOW() - INTERVAL '24 hours'
+ AND status IN ('sent', 'checking', 'delivered')
+ """, (group_id,))
+ recent_count = cur.fetchone()[0]
+ if recent_count > 0:
+ log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
+ continue
+
+ sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
+
+ # ТЕСТОВЫЙ РЕЖИМ
+ send_phone = phone
+ if settings.get("test_mode", True):
+ send_phone = settings.get("test_phone", "79788382260")
+ log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
+
+ log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
+ sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
+
+ if sms_id:
+ log_id = insert_sms_log(conn,
+ campaign_type=CAMPAIGN_TYPE,
+ order_group_id=group_id,
+ customer_phone=phone,
+ sms_id=sms_id,
+ sms_text=sms_text,
+ status="sent",
+ sms_code=code,
+ attempts=1,
+ )
+ # ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
+ update_order_group(conn, group_id, {
+ "notification_status": "second_sms_sending",
+ "sms_sent_at": "NOW()",
+ })
+ log.info(f"Group {group_id}: second SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→second_sms_sending")
+ sent_count += 1
+ else:
+ error = raw[:500] if raw else "Unknown error"
+ log_id = insert_sms_log(conn,
+ campaign_type=CAMPAIGN_TYPE,
+ order_group_id=group_id,
+ customer_phone=phone,
+ sms_text=sms_text,
+ status="send_failed",
+ sms_code=code,
+ attempts=1,
+ error_message=error,
+ )
+ log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
+ update_order_group(conn, group_id, {
+ "last_sms_error": error[:200],
+ })
+ send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
+
+ return sent_count
+
+def step_check_status(conn, settings):
+ """Шаг 2: Проверка статуса ранее отправленных вторых SMS"""
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+ max_duration = settings.get("max_check_duration_minutes", 90)
+ max_attempts = settings.get("max_attempts", 2)
+ auto_manual_hours = settings.get("auto_manual_after_hours", 3)
+
+ sms_list = get_sms_to_check(conn, max_duration)
+ log.info(f"Step 2: {len(sms_list)} second SMS to check status")
+
+ delivered = 0
+ for item in sms_list:
+ log_id = item["log_id"]
+ sms_id = item["sms_id"]
+ group_id = str(item["order_group_id"])
+ name = item.get("customer_name") or item.get("group_key", "—")
+ phone = item.get("customer_phone", "")
+ attempts = item.get("attempts", 1)
+
+ code, raw, api_code = check_sms_status(sms_id, api_id)
+
+ # Сбрасываем needs_check + ставим checked_at
+ update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
+
+ if code == DELIVERED_CODE:
+ # Доставлено!
+ log.info(f"Group {group_id}: second SMS delivered (103)!")
+ update_sms_log(conn, log_id, status="delivered", sms_code=code)
+
+ next_check = (datetime.now(timezone.utc) + timedelta(hours=auto_manual_hours)).isoformat()
+ update_order_group(conn, group_id, {
+ "notification_status": "second_sms_sent",
+ "second_sms_sent_at": "NOW()",
+ "sms_attempts": attempts,
+ "last_sms_error": None,
+ "sms_sent_at": "NOW()",
+ "next_notification_check_at": next_check,
+ "status": "second_sms_sent",
+ })
+ send_telegram(f"✅ Вторая SMS доставлена: {name} ({phone})", tg_chat)
+ delivered += 1
+
+ elif code in IN_TRANSIT_CODES:
+ # В пути / в очереди — продолжаем ждать
+ log.info(f"Group {group_id}: second SMS in transit (code={code}), will check again next run")
+ update_sms_log(conn, log_id, status="checking", sms_code=code)
+
+ elif code in DELIVERY_ERROR_CODES:
+ # Ошибка доставки — можно повторить отправку
+ log.error(f"Group {group_id}: second SMS delivery error (code={code})")
+ update_sms_log(conn, log_id, status="error", sms_code=code,
+ error_message=f"Delivery error: {code}")
+ if attempts < max_attempts:
+ log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}/{max_attempts})")
+ update_sms_log(conn, log_id, status="expired")
+ # Сбрасываем на first_sms_sent для повторной отправки
+ update_order_group(conn, group_id, {
+ "notification_status": "first_sms_sent",
+ })
+ else:
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "last_sms_error": f"Second SMS failed after {max_attempts} attempts (code={code})",
+ })
+ send_telegram(f"⚠️ Вторая SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
+
+ elif code in LIMIT_ERROR_CODES:
+ # Превышен лимит — НЕ повторять
+ log.error(f"Group {group_id}: second SMS limit exceeded (code={code}) — NOT retrying")
+ update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
+ error_message=f"Limit exceeded: code={code}")
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "last_sms_error": f"Second SMS limit exceeded (code={code})",
+ })
+ send_telegram(f"🚫 Вторая SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
+
+ else:
+ # Неизвестный код — логируем, продолжаем проверять
+ log.warning(f"Group {group_id}: unknown SMS code for second SMS: {code}")
+ update_sms_log(conn, log_id, status="checking", sms_code=code,
+ error_message=f"Unknown code: {code}")
+
+ return delivered
+
+def step_handle_expired(conn, settings):
+ """Шаг 3: Обработка вторых SMS с истёкшим сроком проверки"""
+ max_duration = settings.get("max_check_duration_minutes", 90)
+ max_attempts = settings.get("max_attempts", 2)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+
+ expired = get_sms_expired(conn, max_duration)
+ log.info(f"Step 3: {len(expired)} second SMS expired (older than {max_duration} min)")
+
+ for item in expired:
+ log_id = item["log_id"]
+ group_id = str(item["order_group_id"])
+ name = item.get("customer_name") or item.get("group_key", "—")
+ phone = item.get("customer_phone", "")
+ attempts = item.get("attempts", 1)
+
+ log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
+ update_sms_log(conn, log_id, status="expired",
+ error_message=f"Not delivered in {max_duration} minutes")
+
+ if attempts < max_attempts:
+ # Сбрасываем на first_sms_sent для повторной отправки
+ update_order_group(conn, group_id, {
+ "notification_status": "first_sms_sent",
+ })
+ log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}), notification_status→first_sms_sent")
+ else:
+ # Все попытки исчерпаны → ручное управление
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
+ })
+ send_telegram(
+ f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
+ f"Вторая SMS не доставлена после {max_attempts} попыток",
+ tg_chat,
+ )
+
+# ─── Main ────────────────────────────────────────────────────────────────────
+
+def main():
+ log.info("=" * 60)
+ log.info("SMS Second Campaign — START")
+ conn = get_db_conn()
+
+ try:
+ settings = load_settings(conn)
+ log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
+ f"max_duration={settings.get('max_check_duration_minutes')}min, "
+ f"max_attempts={settings.get('max_attempts')}")
+
+ if not settings.get("enabled", True):
+ log.info("Campaign disabled, exiting")
+ return
+
+ # State machine — каждый шаг быстрый, без blocking
+ # Отправка только в рабочие часы (8-21, Пн-Пт по Москве)
+ work_hours = is_within_work_hours(settings)
+ sent = 0
+ if work_hours:
+ sent = step_send_new(conn, settings)
+ else:
+ log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends")
+
+ # Проверка статусов работает всегда — даже ночью
+ delivered = step_check_status(conn, settings)
+ step_handle_expired(conn, settings)
+
+ log.info(f"Run summary: sent={sent}, delivered={delivered}")
+
+ if sent > 0 or delivered > 0:
+ send_telegram(
+ f"📊 Вторая отправка\nОтправлено: {sent}\nДоставлено: {delivered}",
+ settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
+ )
+
+ # Обновляем баланс sms.ru
+ if sent > 0:
+ api_id = settings.get("sms_api_id", SMS_API_ID)
+ balance, raw = fetch_balance(api_id)
+ if balance is not None:
+ with conn.cursor() as cur:
+ cur.execute(
+ "UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'second_sms'",
+ (balance,)
+ )
+ conn.commit()
+ log.info(f"Balance updated: {balance} ₽")
+
+ except Exception as e:
+ log.error(f"Fatal error: {e}", exc_info=True)
+ finally:
+ conn.close()
+
+ log.info("SMS Second Campaign — END")
+ log.info("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx
index 343c22b..c7aea9f 100644
--- a/src/components/admin/SmsCampaignPanel.jsx
+++ b/src/components/admin/SmsCampaignPanel.jsx
@@ -1,20 +1,34 @@
/**
* @file SmsCampaignPanel.jsx
* @description SMS Campaign management for mega_admin.
- * Sub-tabs: Первая отправка | Второе сообщение | Ручное управление | Платное хранение
- * Each tab: settings + log table + "Проверить снова" button + auto-refresh
+ * Campaign selector cards + settings + log table + "Проверить снова" button
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
+import { useNavigate } from "react-router-dom";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
+import { SmsCampaignStats } from "./SmsCampaignStats";
-// ── Campaign tabs ───────────────────────────────────────────────────────────
-const CAMPAIGN_TABS = [
- { key: "first_sms", label: "Первая отправка", icon: "📤" },
- { key: "second_sms", label: "Второе сообщение", icon: "📨" },
- { key: "manual", label: "Ручное управление", icon: "🔧" },
- { key: "paid_storage", label: "Платное хранение", icon: "📦" },
+// ── Campaigns ───────────────────────────────────────────────────────────────
+const CAMPAIGNS = [
+ { key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" },
+ { key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" },
+ { key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" },
+ { key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" },
+];
+
+const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
+
+// ── Days of week ─────────────────────────────────────────────────────────────
+const DAYS = [
+ { num: 1, short: "Пн" },
+ { num: 2, short: "Вт" },
+ { num: 3, short: "Ср" },
+ { num: 4, short: "Чт" },
+ { num: 5, short: "Пт" },
+ { num: 6, short: "Сб" },
+ { num: 7, short: "Вс" },
];
// ── Status labels ────────────────────────────────────────────────────────────
@@ -40,34 +54,25 @@ const STATUS_TONES = {
manual_override: "warning",
};
-// ── SMS code labels (from sms.ru docs) ────────────────────────────────────────
+// ── SMS code labels ──────────────────────────────────────────────────────────
const SMS_CODE_LABELS = {
- "100": "В очереди",
- "101": "Оператору",
- "102": "В пути",
- "103": "Доставлено",
- "104": "Истёкло время",
- "105": "Удалено оператором",
- "106": "Сбой телефона",
- "107": "Неизвестная причина",
- "108": "Отклонено",
- "130": "Лимит на номер/день",
- "131": "Лимит одинаковых/мин",
- "132": "Лимит одинаковых/день",
- "200": "Неправильный api_id",
- "201": "Недостаточно средств",
- "202": "Неправильный получатель",
- "230": "Общий лимит/день",
- "231": "Лимит одинаковых/мин",
+ "100": "В очереди", "101": "Оператору", "102": "В пути",
+ "103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
+ "106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
+ "130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
+ "132": "Лимит одинаковых/день", "200": "Неправильный api_id",
+ "201": "Недостаточно средств", "202": "Неправильный получатель",
+ "230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
"232": "Лимит одинаковых/день",
};
+// ── Helpers ──────────────────────────────────────────────────────────────────
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",
+ hour: "2-digit", minute: "2-digit",
});
} catch { return ts; }
};
@@ -81,17 +86,10 @@ const fmtPhone = (phone) => {
return phone;
};
-/** Минуты с момента created_at */
-const minutesAgo = (ts) => {
- if (!ts) return null;
- const diff = Date.now() - new Date(ts).getTime();
- return Math.floor(diff / 60000);
-};
-
-/** Человекочитаемый "прошло X мин" */
const fmtElapsed = (ts) => {
- const m = minutesAgo(ts);
- if (m === null) return "—";
+ if (!ts) return "—";
+ const diff = Date.now() - new Date(ts).getTime();
+ const m = Math.floor(diff / 60000);
if (m < 1) return "только что";
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
@@ -99,11 +97,21 @@ const fmtElapsed = (ts) => {
return `${h}ч ${rest}м назад`;
};
+const parseWorkDays = (str) => {
+ if (!str) return new Set([1, 2, 3, 4, 5]);
+ return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7));
+};
+
+const serializeWorkDays = (set) => [...set].sort().join(",");
+
// ── Component ────────────────────────────────────────────────────────────────
export const SmsCampaignPanel = () => {
- const [activeTab, setActiveTab] = useState("first_sms");
+ const navigate = useNavigate();
+ const [activeCampaign, setActiveCampaign] = useState("first_sms");
const [logs, setLogs] = useState([]);
const [settings, setSettings] = useState(null);
+ const [allSettings, setAllSettings] = useState({});
+ const [campaignCounts, setCampaignCounts] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all");
@@ -111,49 +119,67 @@ export const SmsCampaignPanel = () => {
const [settingsSaved, setSettingsSaved] = useState(false);
const [checkingIds, setCheckingIds] = useState(new Set());
const [autoRefresh, setAutoRefresh] = useState(true);
+ const [togglingCampaign, setTogglingCampaign] = useState(null);
const refreshTimer = useRef(null);
+ const handleOpenGroup = useCallback((groupId) => {
+ if (groupId) navigate("/dashboard/group/" + groupId);
+ }, [navigate]);
+
// ── Load data ──────────────────────────────────────────────────────────────
const loadData = useCallback(async () => {
setError(null);
try {
- // Load logs for active campaign tab
+ // Load all settings for cards
+ const { data: allSettingsData, error: allErr } = await supabase
+ .from("sms_campaign_settings")
+ .select("*");
+ if (allErr) throw allErr;
+ const settingsMap = {};
+ (allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
+ setAllSettings(settingsMap);
+
+ // Load counts per campaign (for cards)
+ const { data: countsData, error: countsErr } = await supabase
+ .from("sms_campaign_log")
+ .select("campaign_type, status");
+ if (countsErr) throw countsErr;
+ const counts = {};
+ (countsData || []).forEach(r => {
+ if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 };
+ counts[r.campaign_type].total++;
+ if (r.status === "delivered") counts[r.campaign_type].delivered++;
+ if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++;
+ if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++;
+ });
+ setCampaignCounts(counts);
+
+ // Load logs for active campaign
let query = supabase
.from("sms_campaign_log")
.select("*")
- .eq("campaign_type", activeTab)
+ .eq("campaign_type", activeCampaign)
.order("created_at", { ascending: false })
.limit(200);
-
- if (filter !== "all") {
- query = query.eq("status", filter);
- }
-
+ if (filter !== "all") query = query.eq("status", filter);
const { data: logData, error: logError } = await query;
if (logError) throw logError;
setLogs(logData || []);
// Load settings for active campaign
- const { data: settingsData, error: settingsError } = await supabase
- .from("sms_campaign_settings")
- .select("*")
- .eq("campaign_type", activeTab)
- .single();
- if (settingsError && settingsError.code !== "PGRST116") throw settingsError;
- setSettings(settingsData || null);
+ if (HAS_SETTINGS.includes(activeCampaign)) {
+ setSettings(settingsMap[activeCampaign] || null);
+ } else {
+ setSettings(null);
+ }
} catch (e) {
setError(e.message || String(e));
} finally {
setIsLoading(false);
}
- }, [activeTab, filter]);
-
- // Initial load + auto-refresh
- useEffect(() => {
- setIsLoading(true);
- loadData();
- }, [loadData]);
+ }, [activeCampaign, filter]);
+ useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
useEffect(() => {
if (autoRefresh) {
refreshTimer.current = setInterval(() => loadData(), 30000);
@@ -186,7 +212,41 @@ export const SmsCampaignPanel = () => {
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
};
- // ── "Проверить снова" — sets needs_check=true ───────────────────────────────
+ // ── Quick toggle campaign on/off from card ────────────────────────────────
+ const toggleCampaignEnabled = async (campaignKey) => {
+ const s = allSettings[campaignKey];
+ if (!s) return;
+ setTogglingCampaign(campaignKey);
+ const newVal = !(s.enabled ?? true);
+ try {
+ const { error: updateError } = await supabase
+ .from("sms_campaign_settings")
+ .update({ enabled: newVal, updated_at: new Date().toISOString() })
+ .eq("id", s.id);
+ if (updateError) throw updateError;
+ // Update local state
+ setAllSettings(prev => ({
+ ...prev,
+ [campaignKey]: { ...prev[campaignKey], enabled: newVal }
+ }));
+ if (campaignKey === activeCampaign) {
+ setSettings(prev => prev ? { ...prev, enabled: newVal } : prev);
+ }
+ } catch (e) {
+ setError(`Ошибка: ${e.message}`);
+ } finally {
+ setTogglingCampaign(null);
+ }
+ };
+
+ const toggleWorkDay = (dayNum) => {
+ const current = parseWorkDays(settings?.work_days);
+ if (current.has(dayNum)) current.delete(dayNum);
+ else current.add(dayNum);
+ updateSetting("work_days", serializeWorkDays(current));
+ };
+
+ // ── Recheck ────────────────────────────────────────────────────────────────
const handleRecheck = async (logId) => {
setCheckingIds(prev => new Set([...prev, logId]));
try {
@@ -195,79 +255,153 @@ export const SmsCampaignPanel = () => {
.update({ needs_check: true, updated_at: new Date().toISOString() })
.eq("id", logId);
if (updateError) throw updateError;
- // Update local state
- setLogs(prev => prev.map(l =>
- l.id === logId ? { ...l, needs_check: true } : l
- ));
+ setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l));
} catch (e) {
setError(`Ошибка: ${e.message}`);
} finally {
setTimeout(() => {
- setCheckingIds(prev => {
- const next = new Set(prev);
- next.delete(logId);
- return next;
- });
+ setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; });
}, 1000);
}
};
- // ── Stats summary ─────────────────────────────────────────────────────────
+ // ── Stats ──────────────────────────────────────────────────────────────────
const stats = logs.reduce((acc, log) => {
acc[log.status] = (acc[log.status] || 0) + 1;
return acc;
}, {});
- const hasSettings = activeTab === "first_sms"; // Only first_sms has settings for now
+ const showSettings = HAS_SETTINGS.includes(activeCampaign);
+ const isManualCampaign = activeCampaign === "manual";
+ const isPaidStorageCampaign = activeCampaign === "paid_storage";
+ const showSmsFields = !isManualCampaign;
// ── Render ──────────────────────────────────────────────────────────────────
if (isLoading && logs.length === 0) {
return (