From 23c0ed78ef3d4007588ff9e8d6d2f1c0716f3438 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 12:21:54 +0000 Subject: [PATCH] feat: drag-and-drop section ordering in LogisticsReadinessBoard + unified table visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogisticsReadinessBoard: @dnd-kit/sortable for drag-and-drop section reordering - Drag handle (6-dot grip icon) on left of each section header - Custom order persists in localStorage (key: logistics-section-order) - Default: agreed → manual_required → funnel order - Flat rows (no Button/rounded), matches OrdersTable style - 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён - Horizontal scroll: overflow-x-auto + min-w-[1080px] - Type column wide enough for Самовывоз/Доставка in one line - OrdersTable: widened Type column (minmax 70px→100px), min-w 920→1080px for consistent horizontal scroll across tables --- package-lock.json | 56 ++ package.json | 9 +- public/service-worker.js | 4 +- scripts/sms_first_campaign.py | 16 +- scripts/sms_first_campaign_ref.py | 583 +++++++++++++++++ scripts/sms_manual_campaign.py | 218 +++++++ scripts/sms_paid_storage_campaign.py | 469 ++++++++++++++ scripts/sms_second_campaign.py | 586 ++++++++++++++++++ src/components/admin/SmsCampaignPanel.jsx | 567 +++++++++++------ src/components/admin/SmsCampaignStats.jsx | 255 ++++++++ .../logistics/LogisticsReadinessBoard.jsx | 397 ++++++++---- src/components/orders/OrdersTable.jsx | 6 +- src/pages/ClientDeliveryPage.jsx | 5 +- 13 files changed, 2839 insertions(+), 332 deletions(-) create mode 100644 scripts/sms_first_campaign_ref.py create mode 100644 scripts/sms_manual_campaign.py create mode 100644 scripts/sms_paid_storage_campaign.py create mode 100644 scripts/sms_second_campaign.py create mode 100644 src/components/admin/SmsCampaignStats.jsx 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 ( -
Загрузка SMS-логов…
+
Загрузка…
); } return (
- {/* ── Campaign sub-tabs ─────────────────────────────────────────────── */} -
- {CAMPAIGN_TABS.map(tab => ( - - ))} + {/* ── Balance + Campaign cards ─────────────────────────────────────────── */} + {/* Balance banner */} + {allSettings.first_sms?.last_balance != null && ( + +
+ 💰 +
+
Баланс sms.ru
+
+ {allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽ +
+
+
+
+ )} + + {/* Campaign cards */} +
+ {CAMPAIGNS.map(c => { + const isActive = activeCampaign === c.key; + const s = allSettings[c.key]; + const isEnabled = s?.enabled ?? true; + const isTest = s?.test_mode ?? true; + const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 }; + const hasS = HAS_SETTINGS.includes(c.key); + const isToggling = togglingCampaign === c.key; + return ( +
{ setActiveCampaign(c.key); setFilter("all"); }} + > + {/* Header: icon + toggle */} +
+ {c.icon} + {hasS && ( + + )} +
+ + {/* Title */} +
{c.label}
+ + {/* Status badge */} + {hasS && ( +
+ + {!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"} + +
+ )} + + {/* Quick stats */} + {hasS && cnt.total > 0 && ( +
+
+
Всего
+
{cnt.total}
+
+
+
Доставл.
+
{cnt.delivered}
+
+
+
Ошибок
+
{cnt.errors}
+
+
+ )} + + {!hasS && ( +
В разработке
+ )} +
+ ); + })}
{error && (
{error}
- +
)} - {/* ── Settings panel (only for campaigns with settings) ─────────────── */} - {hasSettings && settings && ( + {/* ── Statistics ────────────────────────────────────────────────────── */} + + + {/* ── Settings ──────────────────────────────────────────────────────── */} + {showSettings && settings && (
-

Настройки: {CAMPAIGN_TABS.find(t => t.key === activeTab)?.label}

- {settingsSaved && ( - ✓ Сохранено - )} +

+ {CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label} +

+ {settingsSaved && ✓ Сохранено}
- {/* Test / Production mode toggle */} + {/* Test / Production toggle — только для SMS-кампаний */} + {!isManualCampaign && (
+ )} -
- updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} - /> - updateSetting("max_check_duration_minutes", parseInt(v) || 90)} - /> - updateSetting("max_attempts", parseInt(v) || 2)} - /> - updateSetting("second_sms_delay_hours", parseInt(v) || 3)} - /> - updateSetting("auto_manual_after_hours", parseInt(v) || 3)} - /> - updateSetting("telegram_chat_id", v)} - /> -
-
-