diff --git a/.gitignore b/.gitignore index 1d55b7d..244c37a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist .superpowers .ruff_cache volumes/db/data/ +__pycache__/ +*.pyc +*.bak 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 d6f2361..d0c4088 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-v9"; - const RUNTIME_CACHE = "construction-delivery-runtime-v9"; + const STATIC_CACHE = "construction-delivery-static-v42"; + const RUNTIME_CACHE = "construction-delivery-runtime-v42"; 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 345d142..7224791 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -9,9 +9,14 @@ SuperSam — SMS First Campaign (State Machine) 2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration) 3. Обновляет статусы в order_groups + sms_campaign_log -Защита от повторной отправки: - - Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes → skip - - Код 231/132 = лимит одинаковых → не повторять +Защита от повторной отправки (ДВОЙНАЯ): + 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 = в пути → ждём @@ -23,6 +28,8 @@ import os import sys import json import logging +import time +import fcntl from datetime import datetime, timezone, timedelta import requests @@ -31,7 +38,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") @@ -46,6 +53,10 @@ 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"} # Код доставки @@ -87,14 +98,18 @@ def load_settings(conn): "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""" + """Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.""" clean = "".join(c for c in str(phone) if c.isdigit()) + if len(clean) < 10: + return None if clean.startswith("8"): clean = "7" + clean[1:] elif not clean.startswith("7"): @@ -125,7 +140,7 @@ def send_sms(phone, message, api_id): return None, str(e), "error" def check_sms_status(sms_id, api_id): - """Проверяет статус, возвращает (code, raw_response)""" + """Проверяет статус, возвращает (sms_status_code, raw_response, api_code)""" try: resp = requests.post(SMS_STATUS_URL, params={ "api_id": api_id, @@ -142,22 +157,23 @@ def check_sms_status(sms_id, api_id): 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}") + pass # Telegram notifications moved to n8n+Supabase integration # ─── State Machine ─────────────────────────────────────────────────────────── @@ -166,7 +182,8 @@ def get_groups_to_send(conn): 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.customer_phone_normalized, og.delivery_link, og.notification_status, + og.sms_attempts FROM order_groups og WHERE og.status = 'ready_to_launch' AND og.delivery_status = 'pending_confirmation' @@ -186,29 +203,43 @@ def get_groups_to_send(conn): return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): - """SMS в логе со status='sent'/'checking', которые ещё не доставлены""" + """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.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' - ORDER BY scl.created_at ASC + 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, не доставлены)""" + """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены). + Также считаем общее количество failed попыток для группы. + """ 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 + og.customer_name, og.group_key, og.delivery_link, + og.sms_attempts as group_sms_attempts, + (SELECT COUNT(*) FROM sms_campaign_log scl2 + WHERE scl2.order_group_id = scl.order_group_id + AND scl2.campaign_type = 'first_sms' + AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed FROM sms_campaign_log scl JOIN order_groups og ON og.id = scl.order_group_id WHERE scl.campaign_type = 'first_sms' @@ -233,8 +264,11 @@ def update_sms_log(conn, log_id, **kwargs): set_parts = [] values = [] for k, v in kwargs.items(): - set_parts.append(f"{k} = %s") - values.append(v) + 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() @@ -253,16 +287,46 @@ def update_order_group(conn, group_id, fields): cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values) conn.commit() +# ─── Проверка рабочего времени ──────────────────────────────────────────────── + +def 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): +def step_send_new(conn, settings, test_send=False): """Шаг 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) + if test_send: + # Test mode: send ONE SMS to test phone, pick first available group + all_groups = get_groups_to_send(conn) + if not all_groups: + log.info("Test send: no groups available in queue") + return 0 + groups = [all_groups[0]] # Only first group + log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)") + else: + groups = get_groups_to_send(conn) log.info(f"Step 1: {len(groups)} groups to send SMS") sent_count = 0 @@ -285,24 +349,43 @@ def step_send_new(conn, settings): log.info(f"Group {group_id}: already has recent SMS in log, skipping") continue - sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}" + sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}") + sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms") - log.info(f"Sending SMS to {name} ({phone})") - sms_id, raw, code = send_sms(phone, sms_text, api_id) + # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый + 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: + # Use sms_attempts from order_groups (incremented on retry by step_handle_expired) + current_attempts = (group.get("sms_attempts") or 0) + 1 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, + sent_to=send_phone, status="sent", sms_code=code, - attempts=1, + attempts=current_attempts, + was_test_mode=bool(settings.get("test_mode", True)), ) - log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}") + # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status, + # чтобы get_groups_to_send не нашёл эту группу при следующем запуске + update_order_group(conn, group_id, { + "notification_status": "sms_sending", + "sms_sent_at": "NOW()", + "sms_attempts": current_attempts, + }) + log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending") sent_count += 1 + time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit else: # Ошибка отправки error = raw[:500] if raw else "Unknown error" @@ -311,10 +394,12 @@ def step_send_new(conn, settings): order_group_id=group_id, customer_phone=phone, sms_text=sms_text, + sent_to=send_phone if "send_phone" in dir() else phone, status="send_failed", sms_code=code, attempts=1, error_message=error, + was_test_mode=bool(settings.get("test_mode", True)), ) log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}") @@ -345,9 +430,13 @@ def step_check_status(conn, settings): 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)!") @@ -378,11 +467,12 @@ def step_check_status(conn, settings): error_message=f"Delivery error: {code}") # Переход к следующей попытке отправки (если есть) if attempts < max_attempts: - log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})") - # Сбрасываем, чтобы step_send_new подхватил (но только если нет другой недавней) - # Удаляем лог, чтобы группа снова попала в get_groups_to_send - # Нет — лучше помечаем как expired, а step_send_new проверяет отсутствие активных + log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})") update_sms_log(conn, log_id, status="expired") + update_order_group(conn, group_id, { + "notification_status": "link_ready", + "sms_attempts": attempts, + }) else: update_order_group(conn, group_id, { "notification_status": "send_failed", @@ -425,19 +515,29 @@ def step_handle_expired(conn, settings): phone = item.get("customer_phone", "") attempts = item.get("attempts", 1) delivery_link = item.get("delivery_link", "") + total_failed = item.get("total_failed", 0) - log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") + # Use total_failed across ALL attempts, not just this one + effective_attempts = max(attempts, total_failed) + + log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}") update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} minutes") - if attempts < max_attempts: - # Будет повторная отправка на следующем запуске (step_send_new подхватит) - log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1})") + if effective_attempts < max_attempts: + # Повторная отправка ТЕКУЩЕЙ SMS (не откат назад) + update_order_group(conn, group_id, { + "notification_status": "link_ready", + "sms_attempts": effective_attempts, + }) + log.info(f"Group {group_id}: retry first SMS (attempt {effective_attempts+1}/{max_attempts}), notification_status→link_ready") else: # Все попытки исчерпаны update_order_group(conn, group_id, { "notification_status": "manual_required", + "delivery_status": "manual_confirmation_required", "last_sms_error": f"Not delivered after {max_attempts} attempts", + "status": "manual_required", }) send_telegram( f"🔧 Требуется ручное управление: {name} ({phone})\n" @@ -448,12 +548,47 @@ def step_handle_expired(conn, settings): # ─── Main ──────────────────────────────────────────────────────────────────── def main(): + # File lock — prevent parallel execution + lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (IOError, OSError): + log.info("Another instance is running, exiting") + lock_file.close() + return + log.info("=" * 60) log.info("SMS First Campaign — START") conn = get_db_conn() try: settings = load_settings(conn) + + # Update last_run_at timestamp + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = 'first_sms'", ) + conn.commit() + + # Check test_send_requested — one-time test send + test_send_requested = bool(settings.get("test_send_requested", False)) + if test_send_requested: + # Reset flag immediately + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = 'first_sms'", ) + conn.commit() + log.info("Test send requested — will send ONE SMS to test phone only") + + # Check run_requested — restart scenario (reset all groups to beginning) + run_requested = bool(settings.get('run_requested', False)) + if run_requested: + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'first_sms'") + conn.commit() + with conn.cursor() as cur: + cur.execute("""UPDATE order_groups SET notification_status = 'link_ready', sms_sent_at = NULL, first_sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('first_sms_sent','sms_sending','send_failed','second_sms_sent','second_sms_sending','manual_required','not_started')""") + reset_count = cur.rowcount + conn.commit() + log.info(f'RESTART first_sms: reset {reset_count} groups to link_ready') 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')}") @@ -463,7 +598,15 @@ def main(): return # State machine — каждый шаг быстрый, без blocking - sent = step_send_new(conn, settings) + # Отправка только в рабочие часы + work_hours = is_within_work_hours(settings) + sent = 0 + if work_hours: + sent = step_send_new(conn, settings, test_send=test_send_requested) + else: + log.info("Outside work hours, skipping new SMS sends") + + # Проверка статусов работает всегда delivered = step_check_status(conn, settings) step_handle_expired(conn, settings) @@ -475,10 +618,25 @@ def main(): 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 = '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() + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.close() log.info("SMS First Campaign — END") log.info("=" * 60) diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py new file mode 100644 index 0000000..6a30f97 --- /dev/null +++ b/scripts/sms_manual_campaign.py @@ -0,0 +1,272 @@ +#!/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 fcntl +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): + pass # Telegram notifications moved to n8n+Supabase integration + +# ─── Проверка рабочего времени ──────────────────────────────────────────────── + +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): + """Группы, где срок ожидания истёк и нужно перевести в ручное управление. + + Сценарии: + 1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS + 2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS + 3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа + 4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа + """ + 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.first_sms_sent_at, og.second_sms_sent_at, + og.sms_sent_at, og.next_notification_check_at, + og.sms_attempts + FROM order_groups og + WHERE og.delivery_status = 'pending_confirmation' + AND ( + -- 1. second_sms_sent + срок истёк + (COALESCE(og.notification_status, '') = 'second_sms_sent' + AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())) + -- 2. first_sms_sent + срок истёк (>3h после отправки) + -- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL) + -- Иначе second_campaign не успеет отправить вторую SMS + OR (COALESCE(og.notification_status, '') = 'first_sms_sent' + AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) + AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours' + AND og.second_sms_sent_at IS NOT NULL) + -- 3. link_ready + next_check в прошлом + нет активных SMS в логе + OR (COALESCE(og.notification_status, '') = 'link_ready' + AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) + AND NOT EXISTS ( + SELECT 1 FROM sms_campaign_log scl + WHERE scl.order_group_id = og.id + AND scl.status IN ('sent', 'checking') + AND scl.created_at > NOW() - INTERVAL '2 hours' + )) + -- 4. sms_sending/checking + >3h без доставки (зависло) + OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking') + AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours') + ) + 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 isinstance(v, str) and v.startswith("NOW()"): + set_parts.append(f"{k} = {v}") + 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(): + # File lock — prevent parallel execution + lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (IOError, OSError): + log.info("Another instance is running, exiting") + lock_file.close() + return + + log.info("=" * 60) + log.info("Manual Campaign — START") + conn = get_db_conn() + + try: + settings = load_settings(conn) + + # Update last_run_at timestamp + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) + conn.commit() + + # Check test_send_requested — one-time test send + test_send_requested = bool(settings.get("test_send_requested", False)) + if test_send_requested: + # Reset flag immediately + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) + conn.commit() + log.info("Test send requested — will send ONE SMS to test phone only") + 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 + + if test_send_requested: + log.info("Test send requested for manual campaign — no SMS to send, marking as done") + + # run_requested for manual = just run the step + run_requested = bool(settings.get("run_requested", False)) + if run_requested: + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'") + conn.commit() + log.info("RESTART: manual campaign — will process all eligible groups now") + else: + 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() + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.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..e1433d7 --- /dev/null +++ b/scripts/sms_paid_storage_campaign.py @@ -0,0 +1,546 @@ +#!/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 +import time +import fcntl +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 len(clean) < 10: + return None + 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) + if not clean_phone: + log.error(f"Invalid phone: {phone}") + return None, "Invalid phone", "error" + 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, test_send=False): + api_id = settings.get("sms_api_id", SMS_API_ID) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + + if test_send: + # Test mode: send ONE SMS to test phone, pick first available group + all_groups = get_groups_to_send(conn) + if not all_groups: + log.info("Test send: no groups available in queue") + return 0 + groups = [all_groups[0]] # Only first group + log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)") + else: + 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_template = settings.get("sms_text_template", "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ или согласуйте доставку: {link}") + sms_text = sms_text_template.replace("{link}", 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, + sent_to=send_phone, + status="sent", + sms_code=code, + attempts=1, + was_test_mode=bool(settings.get("test_mode", True)), + ) + 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 + time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit + 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, + sent_to=send_phone if "send_phone" in dir() else phone, + status="send_failed", + sms_code=code, + attempts=1, + error_message=error, + was_test_mode=bool(settings.get("test_mode", True)), + ) + 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}") + attempts = item.get("attempts", 1) + max_attempts = settings.get("max_attempts", 2) + if attempts < max_attempts: + update_sms_log(conn, log_id, status="expired") + # Сброс для retry — вернёмся к исходному статусу + update_order_group(conn, group_id, {"notification_status": "not_started"}) + log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})") + else: + update_order_group(conn, group_id, { + "notification_status": "manual_required", + "last_sms_error": f"Paid storage SMS failed after {max_attempts} attempts (code={code})", + }) + send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat) + 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") + attempts = item.get("attempts", 1) + max_attempts = settings.get("max_attempts", 2) + if attempts < max_attempts: + # Retry: сброс на not_started для повторной отправки + update_order_group(conn, group_id, {"notification_status": "not_started"}) + log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})") + else: + # Все попытки исчерпаны — ручное управление, НЕ paid_storage_sent + update_order_group(conn, group_id, { + "notification_status": "manual_required", + "last_sms_error": f"Paid storage SMS not delivered after {max_attempts} attempts", + }) + send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat) + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + # File lock — prevent parallel execution + lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (IOError, OSError): + log.info("Another instance is running, exiting") + lock_file.close() + return + + log.info("=" * 60) + log.info("Paid Storage Campaign — START") + conn = get_db_conn() + + try: + settings = load_settings(conn) + + # Update last_run_at timestamp + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) + conn.commit() + + # Check test_send_requested — one-time test send + test_send_requested = bool(settings.get("test_send_requested", False)) + if test_send_requested: + # Reset flag immediately + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) + conn.commit() + log.info("Test send requested — will send ONE SMS to test phone only") + + # Check run_requested — restart scenario + run_requested = bool(settings.get('run_requested', False)) + if run_requested: + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'paid_storage'") + conn.commit() + with conn.cursor() as cur: + cur.execute("""UPDATE order_groups SET notification_status = 'not_started', sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'paid_storage' AND notification_status IN ('paid_storage_sent','paid_storage_sending','manual_required','send_failed')""") + reset_count = cur.rowcount + conn.commit() + log.info(f'RESTART paid_storage: reset {reset_count} groups') + 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, test_send=test_send_requested) + 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), + was_test_mode=bool(settings.get("test_mode", True)), + ) + + # Обновляем баланс + 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() + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.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..9ee6137 --- /dev/null +++ b/scripts/sms_second_campaign.py @@ -0,0 +1,654 @@ +#!/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 +import time +import fcntl +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. Возвращает None если пустой.""" + clean = "".join(c for c in str(phone) if c.isdigit()) + if len(clean) < 10: + return None + 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) + if not clean_phone: + log.error(f"Invalid phone: {phone}") + return None, "Invalid phone", "error" + 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): + pass # Telegram notifications moved to n8n+Supabase integration + +# ─── 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, + og.sms_attempts + 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, не доставлены). + Также считаем общее количество failed попыток для группы. + """ + 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, + og.sms_attempts as group_sms_attempts, + (SELECT COUNT(*) FROM sms_campaign_log scl2 + WHERE scl2.order_group_id = scl.order_group_id + AND scl2.campaign_type = 'second_sms' + AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed + 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, test_send=False): + """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования""" + api_id = settings.get("sms_api_id", SMS_API_ID) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + + if test_send: + # Test mode: send ONE SMS to test phone, pick first available group + all_groups = get_groups_to_send(conn) + if not all_groups: + log.info("Test send: no groups available in queue") + return 0 + groups = [all_groups[0]] # Only first group + log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)") + else: + 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_template = settings.get("sms_text_template", "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}") + sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms") + + # ТЕСТОВЫЙ РЕЖИМ + 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: + current_attempts = (group.get("sms_attempts") or 0) + 1 + 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, + sent_to=send_phone, + status="sent", + sms_code=code, + attempts=current_attempts, + was_test_mode=bool(settings.get("test_mode", True)), + ) + # ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending + update_order_group(conn, group_id, { + "notification_status": "second_sms_sending", + "sms_sent_at": "NOW()", + "sms_attempts": current_attempts, + }) + 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 + time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit + 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, + sent_to=send_phone if "send_phone" in dir() else phone, + status="send_failed", + sms_code=code, + attempts=1, + error_message=error, + was_test_mode=bool(settings.get("test_mode", True)), + ) + 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}: retry second SMS (attempt {attempts+1}/{max_attempts})") + update_sms_log(conn, log_id, status="expired") + update_order_group(conn, group_id, { + "notification_status": "first_sms_sent", + "sms_attempts": attempts, + "second_sms_sent_at": None, + "next_notification_check_at": None, + }) + 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) + total_failed = item.get("total_failed", 0) + + effective_attempts = max(attempts, total_failed) + + log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}") + update_sms_log(conn, log_id, status="expired", + error_message=f"Not delivered in {max_duration} minutes") + + if effective_attempts < max_attempts: + # Повторная отправка 2й SMS (остаёмся в текущем шаге) + # second_sms_sent_at = NULL чтобы get_groups_to_send нашёл группу + update_order_group(conn, group_id, { + "notification_status": "first_sms_sent", + "sms_attempts": effective_attempts, + "second_sms_sent_at": None, + "next_notification_check_at": None, + }) + log.info(f"Group {group_id}: retry second SMS (attempt {effective_attempts+1}/{max_attempts}), stays in second step") + else: + # Все попытки исчерпаны → ручное управление + update_order_group(conn, group_id, { + "notification_status": "manual_required", + "delivery_status": "manual_confirmation_required", + "last_sms_error": f"Second SMS not delivered after {max_attempts} attempts", + "status": "manual_required", + }) + send_telegram( + f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n" + f"Вторая SMS не доставлена после {max_attempts} попыток", + tg_chat, + ) + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + # File lock — prevent parallel execution + lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (IOError, OSError): + log.info("Another instance is running, exiting") + lock_file.close() + return + + log.info("=" * 60) + log.info("SMS Second Campaign — START") + conn = get_db_conn() + + try: + settings = load_settings(conn) + + # Update last_run_at timestamp + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) + conn.commit() + + # Check test_send_requested — one-time test send + test_send_requested = bool(settings.get("test_send_requested", False)) + if test_send_requested: + # Reset flag immediately + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) + conn.commit() + log.info("Test send requested — will send ONE SMS to test phone only") + + # Check run_requested — restart scenario (reset all groups to beginning) + run_requested = bool(settings.get('run_requested', False)) + if run_requested: + with conn.cursor() as cur: + cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'second_sms'") + conn.commit() + # Reset groups that passed first_sms back to first_sms_sent — restart second SMS scenario + with conn.cursor() as cur: + cur.execute("""UPDATE order_groups SET notification_status = 'first_sms_sent', sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('second_sms_sent','second_sms_sending','manual_required','send_failed')""") + reset_count = cur.rowcount + conn.commit() + log.info(f'RESTART second_sms: reset {reset_count} groups to first_sms_sent') + 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, test_send=test_send_requested) + 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() + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.close() + + log.info("SMS Second Campaign — END") + log.info("=" * 60) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/sms_timer_manager.sh b/scripts/sms_timer_manager.sh new file mode 100755 index 0000000..5302c58 --- /dev/null +++ b/scripts/sms_timer_manager.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# sms_timer_manager.sh — reconciles DB flags with systemd timers +# Runs every 1 min via cron: +# 1. timer_active → enable/disable systemd timer +# 2. run_requested → run campaign script immediately (script resets flag itself) + +set -euo pipefail + +DB_HOST="10.0.4.12" +DB_PORT="5432" +DB_NAME="postgres" +DB_USER="supabase_admin" +DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479" + +declare -A TIMERS=( + ["first_sms"]="sms-first-campaign.timer" + ["second_sms"]="sms-second-campaign.timer" + ["manual"]="sms-manual-campaign.timer" + ["paid_storage"]="sms-paid-storage-campaign.timer" +) +declare -A SERVICES=( + ["first_sms"]="sms-first-campaign.service" + ["second_sms"]="sms-second-campaign.service" + ["manual"]="sms-manual-campaign.service" + ["paid_storage"]="sms-paid-storage-campaign.service" +) +declare -A SCRIPTS=( + ["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py" + ["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py" + ["manual"]="/opt/supersam/scripts/sms_manual_campaign.py" + ["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py" +) + +QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings" +RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "") + +if [ -z "$RESULTS" ]; then + echo "$(date): Failed to query DB, skipping" + exit 0 +fi + +while IFS='|' read -r campaign_type timer_active run_requested; do + [ -z "$campaign_type" ] && continue + timer_name="${TIMERS[$campaign_type]:-}" + service_name="${SERVICES[$campaign_type]:-}" + script_path="${SCRIPTS[$campaign_type]:-}" + [ -z "$timer_name" ] && continue + + if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then + continue + fi + + # ── 1. Timer enable/disable ── + is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive") + + if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then + if [ "$is_active" != "active" ]; then + systemctl enable --now "$timer_name" 2>/dev/null + echo "$(date): ENABLED $timer_name for $campaign_type" + fi + else + if [ "$is_active" = "active" ]; then + systemctl disable --now "$timer_name" 2>/dev/null + echo "$(date): DISABLED $timer_name for $campaign_type" + fi + fi + + # ── 2. Run requested → immediate execution ── + # Don't reset flag here — script reads it from DB and resets after processing + if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then + is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive") + if [ "$is_running" = "active" ]; then + echo "$(date): $campaign_type already running, skipping run_requested" + continue + fi + + echo "$(date): RUN REQUESTED — starting $campaign_type immediately" + systemctl start "$service_name" 2>/dev/null || { + nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 & + echo "$(date): Started $campaign_type via nohup fallback" + } + fi + +done <<< "$RESULTS" \ No newline at end of file diff --git a/src/components/admin/SmsCampaignStats.jsx b/src/components/admin/SmsCampaignStats.jsx new file mode 100644 index 0000000..982cb7a --- /dev/null +++ b/src/components/admin/SmsCampaignStats.jsx @@ -0,0 +1,255 @@ +/** + * @file SmsCampaignStats.jsx + * @description SMS Campaign statistics with charts: + * - KPI cards (sent, delivered, in transit, errors) + * - Daily trend (sent vs delivered, last 14 days) + * - Status donut chart + * - Delivery rate + */ +import React, { useState, useEffect, useCallback } from "react"; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend, +} from "recharts"; +import { Panel } from "../UI/Panel"; +import { supabase } from "../../supabaseClient"; + +const STATUS_COLORS = { + delivered: "#22c55e", + sent: "#3b82f6", + checking: "#94a3b8", + expired: "#eab308", + send_failed: "#ef4444", + error: "#ef4444", + limit_exceeded: "#f97316", +}; + +const STATUS_LABELS = { + delivered: "Доставлено", + sent: "Отправлено", + checking: "Проверяется", + expired: "Истёк срок", + send_failed: "Ошибка отправки", + error: "Ошибка доставки", + limit_exceeded: "Лимит", +}; + +export const SmsCampaignStats = ({ campaignType }) => { + const [stats, setStats] = useState(null); + const [dailyData, setDailyData] = useState([]); + const [statusData, setStatusData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [period, setPeriod] = useState("14d"); + + const loadStats = useCallback(async () => { + setIsLoading(true); + try { + const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14; + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const startDateISO = startDate.toISOString(); + + const { data, error } = await supabase + .from("sms_campaign_log") + .select("status, sms_code, attempts, created_at, campaign_type") + .eq("campaign_type", campaignType) + .gte("created_at", startDateISO) + .order("created_at", { ascending: true }); + + if (error) throw error; + + // KPI + const total = data.length; + const delivered = data.filter(d => d.status === "delivered").length; + const inTransit = data.filter(d => d.status === "sent" || d.status === "checking").length; + const errors = data.filter(d => ["send_failed", "error", "limit_exceeded"].includes(d.status)).length; + const expired = data.filter(d => d.status === "expired").length; + const deliveryRate = total > 0 ? Math.round((delivered / total) * 100) : 0; + + setStats({ total, delivered, inTransit, errors, expired, deliveryRate }); + + // Daily data + const byDay = {}; + data.forEach(d => { + const day = new Date(d.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); + if (!byDay[day]) byDay[day] = { date: day, sent: 0, delivered: 0, errors: 0 }; + byDay[day].sent++; + if (d.status === "delivered") byDay[day].delivered++; + if (["send_failed", "error", "limit_exceeded"].includes(d.status)) byDay[day].errors++; + }); + setDailyData(Object.values(byDay)); + + // Status pie + const byStatus = {}; + data.forEach(d => { + byStatus[d.status] = (byStatus[d.status] || 0) + 1; + }); + setStatusData(Object.entries(byStatus).map(([name, value]) => ({ + name: STATUS_LABELS[name] || name, + value, + color: STATUS_COLORS[name] || "#94a3b8", + }))); + } catch (e) { + console.error("Stats error:", e); + } finally { + setIsLoading(false); + } + }, [campaignType, period]); + + useEffect(() => { loadStats(); }, [loadStats]); + + if (isLoading) { + return ( + +
Загрузка статистики…
+
+ ); + } + + if (!stats || stats.total === 0) { + return ( + +
Нет данных для статистики
+
+ ); + } + + return ( +
+ {/* ── Period selector ──────────────────────────────────────────────── */} +
+ {[ + { key: "7d", label: "7 дней" }, + { key: "14d", label: "14 дней" }, + { key: "30d", label: "30 дней" }, + { key: "all", label: "Всё время" }, + ].map(p => ( + + ))} +
+ + {/* ── KPI cards ────────────────────────────────────────────────────── */} +
+ + + + + + +
+ + {/* ── Charts ───────────────────────────────────────────────────────── */} +
+ {/* Daily trend */} + +
📈 Отправки по дням
+ + + + + + + + + + + + +
+ + {/* Status donut */} + +
🍩 По статусам
+ + + + {statusData.map((entry, i) => ( + + ))} + + + + + +
+
+ + {/* ── Delivery rate trend ──────────────────────────────────────────── */} + {dailyData.length > 1 && ( + +
📊 Конверсия доставки по дням
+ + ({ + date: d.date, + rate: d.sent > 0 ? Math.round((d.delivered / d.sent) * 100) : 0, + }))} + margin={{ top: 5, right: 10, left: -20, bottom: 5 }} + > + + + + [`${v}%`, "Конверсия"]} + /> + + + +
+ )} +
+ ); +}; + +// ── KPI Card ───────────────────────────────────────────────────────────────── +const KpiCard = ({ label, value, color }) => ( +
+
{label}
+
{value}
+
+); \ No newline at end of file diff --git a/src/components/driver/DriverDeliveryDetail.jsx b/src/components/driver/DriverDeliveryDetail.jsx index 6364e9e..0679f9e 100644 --- a/src/components/driver/DriverDeliveryDetail.jsx +++ b/src/components/driver/DriverDeliveryDetail.jsx @@ -88,7 +88,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { { value: "Доставлен", label: "Доставлено" }, { value: "Проблема доставки", label: "Проблема" }, ]; - } else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") { + } else if (currentStatus === "Доставлен" || currentStatus === "Вывезено" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") { actionButtons = []; } else { actionButtons = availableTransitions.map((status) => ({ diff --git a/src/components/driver/DriverDeliveryPlanner.jsx b/src/components/driver/DriverDeliveryPlanner.jsx index c8eaa9b..4fe743f 100644 --- a/src/components/driver/DriverDeliveryPlanner.jsx +++ b/src/components/driver/DriverDeliveryPlanner.jsx @@ -419,9 +419,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs {getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""} - - {item.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"} -
diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index 3d5eef2..d8e774b 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -14,9 +14,73 @@ import { OrderFilters } from "../orders/OrderFilters"; import { formatDate, formatDateTime } from "../../utils/formatters"; export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => { - const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" }); + const STORAGE_KEY = "logistics-filters"; + const savedFilters = (() => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); } catch { return null; } })(); + const [filters, setFilters] = React.useState(savedFilters || { query: "", displayStatus: "all", city: "" }); + React.useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch {} }, [filters]); const [page, setPage] = React.useState(1); - const [collapsedSections, setCollapsedSections] = React.useState(new Set()); + const savedCollapsed = (() => { try { return new Set(JSON.parse(localStorage.getItem("logistics-collapsed") || "[]")); } catch { return new Set(); } })(); + const [collapsedSections, setCollapsedSections] = React.useState(savedCollapsed); + React.useEffect(() => { try { localStorage.setItem("logistics-collapsed", JSON.stringify([...collapsedSections])); } catch {} }, [collapsedSections]); + const [draggingStatus, setDraggingStatus] = React.useState(null); + const [dragOverStatus, setDragOverStatus] = React.useState(null); + const savedOrder = (() => { try { return JSON.parse(localStorage.getItem("logistics-section-order") || "null"); } catch { return null; } })(); + const [sectionOrder, setSectionOrder] = React.useState(savedOrder || null); + React.useEffect(() => { if (sectionOrder) { try { localStorage.setItem("logistics-section-order", JSON.stringify(sectionOrder)); } catch {} } }, [sectionOrder]); + + // Touch drag for mobile + const [touchDragging, setTouchDragging] = React.useState(null); + const touchStartY = React.useRef(null); + const touchStartStatus = React.useRef(null); + + const handleTouchStart = (statusValue, e) => { + if (e.target.closest(".drag-handle")) { + touchStartY.current = e.touches[0].clientY; + touchStartStatus.current = statusValue; + setTouchDragging(statusValue); + } + }; + + const handleTouchMove = (e) => { + if (!touchStartStatus.current) return; + e.preventDefault(); + const touch = e.touches[0]; + const el = document.elementFromPoint(touch.clientX, touch.clientY); + const section = el?.closest("[data-section-key]"); + if (section) { + const overKey = section.getAttribute("data-section-key"); + if (overKey && overKey !== touchStartStatus.current) { + setDragOverStatus(overKey); + } + } + }; + + const handleTouchEnd = (e) => { + if (!touchStartStatus.current) return; + const touch = e.changedTouches[0]; + const el = document.elementFromPoint(touch.clientX, touch.clientY); + const section = el?.closest("[data-section-key]"); + if (section) { + const overKey = section.getAttribute("data-section-key"); + if (overKey && overKey !== touchStartStatus.current) { + const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER; + const entries = Array.from(statusGroups.keys()); + const fullOrder = [...new Set([...order.filter(k => entries.includes(k)), ...entries])]; + const fromIdx = fullOrder.indexOf(touchStartStatus.current); + const toIdx = fullOrder.indexOf(overKey); + if (fromIdx !== -1 && toIdx !== -1) { + const newOrd = [...fullOrder]; + newOrd.splice(fromIdx, 1); + newOrd.splice(toIdx, 0, touchStartStatus.current); + setSectionOrder(newOrd); + } + } + } + setTouchDragging(null); + setDragOverStatus(null); + touchStartStatus.current = null; + touchStartY.current = null; + }; const PAGE_SIZE = 30; const cities = React.useMemo(() => { @@ -113,8 +177,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO ) : (
{Array.from(statusGroups.entries()).sort(([a], [b]) => { - const idxA = FUNNEL_ORDER.indexOf(a); - const idxB = FUNNEL_ORDER.indexOf(b); + const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER; + const idxA = order.indexOf(a); + const idxB = order.indexOf(b); if (idxA === -1 && idxB === -1) return a.localeCompare(b); if (idxA === -1) return 1; if (idxB === -1) return -1; @@ -124,22 +189,46 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO return (
- {/* Section header — same style as OrdersTable */} - +
{!isCollapsed && (
@@ -179,7 +268,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
{group.deliveryDate ? {formatDate(group.deliveryDate)}{group.deliveryTime ? · {group.deliveryTime} : ""} - : + : group.updatedAt + ? {formatDate(group.updatedAt)} + : }
diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index c4978b6..16c5284 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -643,10 +643,32 @@ export const OrderDetailPanel = ({ || (deliveryType === "delivery" && !isPickupOrder) ); const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole); - const agreedDeliveryLabel = [ - formatDeliveryDateDisplay(order.deliveryDate), - order.deliveryTime || order.deliveryHalfDay, - ].filter((value) => value && value !== "Нет данных").join(" · "); + const agreedDeliveryLabel = (() => { + if (isPickupOrder) { + const parts = [ + formatDeliveryDateDisplay(order.pickupDate || order.pickup_date), + order.pickupTimeSlot || order.pickup_time_slot || order.deliveryTime || order.deliveryHalfDay, + ].filter((value) => value && value !== "Нет данных"); + if (parts.length > 0) return parts.join(" · "); + // Fallback: show manual_confirmation_at date if no pickup date + if (order.manualConfirmationAt || order.manual_confirmation_at) { + const mcDate = order.manualConfirmationAt || order.manual_confirmation_at; + return `Согласовано: ${fmtTime(mcDate)}`; + } + return ""; + } + const parts = [ + formatDeliveryDateDisplay(order.deliveryDate), + order.deliveryTime || order.deliveryHalfDay, + ].filter((value) => value && value !== "Нет данных"); + if (parts.length > 0) return parts.join(" · "); + // Fallback: show manual_confirmation_at date if no delivery date + if (order.manualConfirmationAt || order.manual_confirmation_at) { + const mcDate = order.manualConfirmationAt || order.manual_confirmation_at; + return `Согласовано: ${fmtTime(mcDate)}`; + } + return ""; + })(); const handleSaveDeliveryChoice = async () => { const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate; @@ -937,7 +959,7 @@ export const OrderDetailPanel = ({ {deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}

- {agreedDeliveryLabel || "Дата и время сохранены"} + {agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}

Согласовано @@ -1033,7 +1055,13 @@ export const OrderDetailPanel = ({ if (action.type === "hint") { setFormMessage(action.hint); } else if (action.type === "status") { - setConfirmAction({ type: "status", status: action.status }); + setConfirmAction({ + type: "status", + status: action.status, + label: action.label, + mismatch: action.mismatch, + deliveryType: action.deliveryType, + }); } }} /> @@ -1077,33 +1105,49 @@ export const OrderDetailPanel = ({ const currentStatus = order.deliveryStatus || order.delivery_status; const IN_TRANSIT_STATUSES = ["loaded", "on_route"]; const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus); + const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup"; let statusOptions = []; if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") { statusOptions = []; } else { - statusOptions = [ - { value: "delivered", label: "Доставлено" }, - { value: "picked_up", label: "Вывезено" }, - { value: "problem", label: "Проблема" }, - ]; + // Primary button matches delivery type, secondary requires confirmation + if (isPickup) { + statusOptions = [ + { value: "picked_up", label: "Вывезено", mismatch: false }, + { value: "delivered", label: "Доставлено", mismatch: true }, + { value: "problem", label: "Проблема" }, + ]; + } else { + statusOptions = [ + { value: "delivered", label: "Доставлено", mismatch: false }, + { value: "picked_up", label: "Вывезено", mismatch: true }, + { value: "problem", label: "Проблема" }, + ]; + } } if (statusOptions.length === 0) return null; return statusOptions.map((statusOption) => { const isSelected = pendingStatus?.value === statusOption.value; + const isMismatch = statusOption.mismatch; return (
{pendingStatus ? (
+ {pendingStatus.mismatch ? ( +
+

+ ⚠ Статус возможно не совпадает +

+

+ Тип отгрузки: {pendingStatus.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}. + Вы выбрали: «{pendingStatus.label}». + {pendingStatus.deliveryType === "pickup" + ? "Обычно при самовывозе используется «Вывезено»." + : "Обычно при доставке используется «Доставлено»."} +

+

Подтвердить действие?

+
+ ) : ( +

+ Установить статус: {pendingStatus.label}? +

+ )} - - {/* Rows */} - {isOpen && ( -
- {groups.map((group) => { - const hasProblem = group.hasDeliveryProblem; - const isAgreedRow = isAgreedDelivery(group); - const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(80px,0.8fr)] gap-0 w-full border-t text-left transition ${ - isAgreedRow - ? "border-[rgba(18,128,92,0.25)] bg-[rgba(18,128,92,0.07)] hover:bg-[rgba(18,128,92,0.12)]" - : hasProblem - ? "border-[var(--color-border)] bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]" - : "border-[var(--color-border)] hover:bg-[var(--color-accent-soft)]" - } ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`; - - const billNumbers = group.allBillNumbers || group.orderNumbers || []; - const primaryBill = billNumbers[0] || "—"; - const totalBills = billNumbers.length; - - return ( - - ); - })} -
- )} -
- ); + return parts.join(" · "); }; -// ── Mobile collapsible section ─────────────────────────────────────────────── -const MobileStatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrderGroupId, onOpenOrder }) => { - const label = getOrderGroupDisplayStatusLabel(groups[0]); - const tone = getOrderGroupStatusTone(groups[0]); - const isAgreed = statusKey.startsWith("delivery:agreed") || statusKey.startsWith("delivery:pickup") || - statusKey.startsWith("delivery:driver_assigned") || statusKey.startsWith("delivery:loaded") || - statusKey.startsWith("delivery:on_route") || statusKey.startsWith("delivery:delivered") || - statusKey.startsWith("delivery:picked_up"); +const renderOrderNumbers = (group) => { + const numbers = group.allBillNumbers || group.orderNumbers; + if (!Array.isArray(numbers) || !numbers.length) { + return "Номера не указаны"; + } + if (numbers.length <= MAX_VISIBLE_INVOICES) { + return numbers.join(", "); + } + const visible = numbers.slice(0, MAX_VISIBLE_INVOICES); + const remaining = numbers.length - MAX_VISIBLE_INVOICES; + return `${visible.join(", ")} +${remaining}`; +}; + +const getDeliveryTypeLabel = (deliveryType) => { + if (deliveryType === "pickup") return "Самовывоз"; + if (deliveryType === "delivery") return "Доставка"; + return "—"; +}; + +const renderMobileOrderNumbers = (group) => { + const numbers = group.allBillNumbers || group.orderNumbers; + if (!Array.isArray(numbers) || !numbers.length) { + return "Номера не указаны"; + } + + if (numbers.length <= MAX_VISIBLE_INVOICES) { + return numbers.join(", "); + } + const visible = numbers.slice(0, MAX_VISIBLE_INVOICES); + const remaining = numbers.length - MAX_VISIBLE_INVOICES; return ( -
- - {isOpen && ( -
- {groups.map((group) => { - const hasProblem = group.hasDeliveryProblem; - const isAgreedCard = isAgreedDelivery(group); - const baseClass = "w-full rounded-[22px] border text-left transition"; - const selectedClass = selectedOrderGroupId === group.id - ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" - : isAgreedCard - ? "border-[rgba(18,128,92,0.35)] bg-[rgba(18,128,92,0.08)]" - : hasProblem - ? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]" - : "border-[var(--color-border)] bg-[var(--color-surface-strong)]"; - - const allNumbers = group.allBillNumbers || group.orderNumbers || []; - const primaryBill = allNumbers[0] || "—"; - const totalCount = allNumbers.length; - - return ( -
-
- № {primaryBill} - {totalCount > 1 && ( - - {totalCount} сч. - - )} -
- -
- ); - })} -
- )} -
+ <> + {visible.join(", ")} + +{remaining} + ); }; @@ -261,48 +89,6 @@ export const OrdersTable = ({ cities = [], isLoading = false, }) => { - const [collapsedSections, setCollapsedSections] = useState({}); - const [page, setPage] = useState(1); - const PAGE_SIZE = 30; - - // Paginate filtered groups (after filter, before grouping) - const totalPages = Math.ceil(orderGroups.length / PAGE_SIZE); - const paginatedGroups = useMemo(() => { - const start = (page - 1) * PAGE_SIZE; - return orderGroups.slice(start, start + PAGE_SIZE); - }, [orderGroups, page]); - - // Group by status - const grouped = useMemo(() => { - const map = {}; - for (const g of paginatedGroups) { - const key = getOrderGroupDisplayStatusValue(g); - if (!map[key]) map[key] = []; - map[key].push(g); - } - // Sort by priority - return Object.entries(map) - .map(([key, groups]) => ({ key, groups, order: getStatusOrder(key) })) - .sort((a, b) => a.order - b.order); - }, [orderGroups]); - - // Default: first section open, rest collapsed (only if not manually toggled) - const effectiveCollapsed = useMemo(() => { - const result = {}; - grouped.forEach((sec, i) => { - if (sec.key in collapsedSections) { - result[sec.key] = collapsedSections[sec.key]; - } else { - result[sec.key] = i !== 0; // first section open by default - } - }); - return result; - }, [grouped, collapsedSections]); - - const toggleSection = (key) => { - setCollapsedSections(prev => ({ ...prev, [key]: !prev[key] })); - }; - if (isLoading) { return ; } @@ -325,27 +111,66 @@ export const OrdersTable = ({ ) : null}
- {/* Mobile: collapsible sections */} -
+
{!orderGroups.length ? (
Группы не найдены. Попробуйте изменить поиск или статус.
) : null} - {grouped.map(({ key, groups }) => ( - toggleSection(key)} - selectedOrderGroupId={selectedOrderGroupId} - onOpenOrder={onOpenOrder} - /> - ))} + {orderGroups.map((group) => { + const hasProblem = group.hasDeliveryProblem; + const baseClass = "w-full rounded-[22px] border text-left transition"; + const selectedClass = selectedOrderGroupId === group.id + ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" + : hasProblem + ? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]" + : "border-[var(--color-border)] bg-[var(--color-surface-strong)]"; + + const allNumbers = group.allBillNumbers || group.orderNumbers || []; + const primaryBill = allNumbers[0] || "—"; + const totalCount = allNumbers.length; + + return ( +
+
+ № {primaryBill} + {totalCount > 1 && ( + + {totalCount} сч. + + )} +
+ +
+ ); + })}
- {/* Desktop: collapsible sections with table header */}
{!orderGroups.length ? (
@@ -353,25 +178,80 @@ export const OrdersTable = ({
) : (
-
- {grouped.map(({ key, groups }) => ( - toggleSection(key)} - selectedOrderGroupId={selectedOrderGroupId} - onOpenOrder={onOpenOrder} - /> - ))} +
+
+
Группа / Клиент
+
Счета
+
Город
+
Статус
+
Дата доставки
+
Тип
+
Водитель
+
+ {orderGroups.map((group) => { + const hasProblem = group.hasDeliveryProblem; + const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${ + hasProblem + ? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]" + : "hover:bg-[var(--color-accent-soft)]" + } ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`; + + const billNumbers = group.allBillNumbers || group.orderNumbers || []; + const primaryBill = billNumbers[0] || "—"; + const totalBills = billNumbers.length; + + return ( + + ); + })}
)}
- {totalPages > 1 && ( - - )} ); }; \ No newline at end of file diff --git a/src/components/orders/SmsStatusCard.jsx b/src/components/orders/SmsStatusCard.jsx new file mode 100644 index 0000000..3f56c95 --- /dev/null +++ b/src/components/orders/SmsStatusCard.jsx @@ -0,0 +1,288 @@ +/** + * @file SmsStatusCard.jsx + * @description SMS status + restart button for a specific delivery group. + * Shows: current notification status, when SMS was sent, countdown to next SMS, + * and a "Restart SMS" button that resets this group's notification status. + */ +import React, { useState, useEffect, useCallback } from "react"; +import { Panel } from "../UI/Panel"; +import { Badge } from "../UI/Badge"; +import { supabase } from "../../supabaseClient"; + +// ── Status labels ──────────────────────────────────────────────────────────── +const NOTIF_LABELS = { + not_started: "Не отправлено", + link_ready: "В очереди на отправку", + sms_sending: "Отправляется…", + first_sms_sent: "1-е SMS отправлено", + second_sms_sending: "2-я SMS отправляется…", + second_sms_sent: "2-е SMS отправлено", + send_failed: "Ошибка отправки", + manual_required: "Требуется ручное управление", + paid_storage_sending: "Отправляется…", + paid_storage_sent: "Платное хранение: отправлено", + draft: "Черновик", +}; + +const NOTIF_TONES = { + not_started: "neutral", + link_ready: "warning", + sms_sending: "info", + first_sms_sent: "info", + second_sms_sending: "info", + second_sms_sent: "accent", + send_failed: "danger", + manual_required: "warning", + paid_storage_sending: "info", + paid_storage_sent: "accent", + draft: "neutral", +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── +const fmtTime = (ts) => { + if (!ts) return "—"; + try { + return new Date(ts).toLocaleString("ru-RU", { + day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", + }); + } catch { return ts; } +}; + +const fmtCountdown = (targetTs) => { + if (!targetTs) return null; + const diff = new Date(targetTs).getTime() - Date.now(); + if (diff <= 0) return "готово к отправке"; + const m = Math.floor(diff / 60000); + const s = Math.floor((diff % 60000) / 1000); + if (m >= 60) { + const h = Math.floor(m / 60); + const restM = m % 60; + return `${h}ч ${restM}м`; + } + return `${m}м ${s}с`; +}; + +// ── Component ──────────────────────────────────────────────────────────────── +export const SmsStatusCard = ({ order, userRole }) => { + const [restarting, setRestarting] = useState(false); + const [restartDone, setRestartDone] = useState(false); + const [now, setNow] = useState(Date.now()); + const [smsLog, setSmsLog] = useState([]); + const [loadingLog, setLoadingLog] = useState(false); + + // Tick every 1s for countdown + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + // Load SMS log for this group + const loadSmsLog = useCallback(async () => { + if (!order?.id) return; + setLoadingLog(true); + try { + const { data, error } = await supabase + .from("sms_campaign_log") + .select("*") + .eq("order_group_id", order.id) + .order("created_at", { ascending: false }) + .limit(10); + if (error) throw error; + setSmsLog(data || []); + } catch (e) { + // Silent fail — not critical + } finally { + setLoadingLog(false); + } + }, [order?.id]); + + useEffect(() => { loadSmsLog(); }, [loadSmsLog]); + + // Can manage? + const canManage = ["mega_admin", "admin"].includes(userRole); + + const notifStatus = order.notificationStatus || order.notification_status || "not_started"; + const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at; + const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at; + const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at; + const smsSentAt = order.smsSentAt || order.sms_sent_at; + const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0; + const lastError = order.lastSmsError || order.last_sms_error; + + // Restart: reset this group's notification_status to link_ready + const handleRestart = async () => { + if (!order?.id) return; + setRestarting(true); + try { + // Reset this group to link_ready — restart SMS flow from beginning + const { error: updateError } = await supabase + .from("order_groups") + .update({ + notification_status: "link_ready", + sms_sent_at: null, + first_sms_sent_at: null, + second_sms_sent_at: null, + next_notification_check_at: null, + sms_attempts: 0, + last_sms_error: null, + }) + .eq("id", order.id); + if (updateError) throw updateError; + setRestartDone(true); + setTimeout(() => setRestartDone(false), 3000); + // Reload log + loadSmsLog(); + } catch (e) { + console.error("Restart SMS error:", e); + } finally { + setRestarting(false); + } + }; + + // Restart second SMS only (keep first SMS sent) + const handleRestartSecond = async () => { + if (!order?.id) return; + setRestarting(true); + try { + const { error: updateError } = await supabase + .from("order_groups") + .update({ + notification_status: "first_sms_sent", + second_sms_sent_at: null, + next_notification_check_at: null, + sms_attempts: 0, + last_sms_error: null, + }) + .eq("id", order.id); + if (updateError) throw updateError; + setRestartDone(true); + setTimeout(() => setRestartDone(false), 3000); + loadSmsLog(); + } catch (e) { + console.error("Restart second SMS error:", e); + } finally { + setRestarting(false); + } + }; + + const countdown = nextCheck ? fmtCountdown(nextCheck) : null; + // Determine SMS display state + const hasSmsSent = !!smsSentAt; // SMS physically sent (may not be delivered yet) + const hasFirstSms = !!firstSmsAt; // First SMS confirmed delivered (103) + const hasSecondSms = !!secondSmsAt; // Second SMS confirmed delivered (103) + + return ( + +
+

📱 SMS-уведомления

+ + {NOTIF_LABELS[notifStatus] || notifStatus} + +
+ + {/* Timeline */} +
+ {/* 1st SMS */} +
+ +
+
1-е SMS
+ {hasFirstSms ? ( +
{fmtTime(firstSmsAt)} ✓ доставлено
+ ) : hasSmsSent && notifStatus === "sms_sending" ? ( +
{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…
+ ) : notifStatus === "link_ready" ? ( +
в очереди на отправку
+ ) : ( +
не отправлено
+ )} +
+
+ + {/* 2nd SMS */} +
+ +
+
2-е SMS
+ {hasSecondSms ? ( +
{fmtTime(secondSmsAt)}
+ ) : notifStatus === "first_sms_sent" && countdown ? ( +
+ отправка через {countdown} +
+ ) : ( +
+ )} +
+
+ + {/* Next check countdown */} + {countdown && notifStatus !== "second_sms_sent" && ( +
+ ⏱ Следующая проверка: + {countdown} +
+ )} + + {/* Error */} + {lastError && ( +
+ ⚠ {lastError} +
+ )} + + {/* Attempts */} + {smsAttempts > 0 && ( +
Попыток отправки: {smsAttempts}
+ )} +
+ + {/* SMS log for this group */} + {smsLog.length > 0 && ( +
+
История SMS
+
+ {smsLog.map((log) => ( +
+ {fmtTime(log.created_at)} + + {log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status} + + {log.sent_to && ( + → {log.sent_to} + )} + {log.sms_code && ( + код: {log.sms_code} + )} +
+ ))} +
+
+ )} + + {/* Restart buttons */} + {canManage && ( +
+ + {hasFirstSms && !hasSecondSms && ( + + )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/orders/StatusActionPanel.jsx b/src/components/orders/StatusActionPanel.jsx index ba2fce0..99ca7a5 100644 --- a/src/components/orders/StatusActionPanel.jsx +++ b/src/components/orders/StatusActionPanel.jsx @@ -18,6 +18,32 @@ const StatusActionPanel = ({ } const currentStatus = order.deliveryStatus || order.delivery_status; + const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup"; + + // Build status options — primary matches delivery type, secondary is mismatch + const allStatuses = isPickup + ? [ + { value: "pending_confirmation", label: "Ожидает согласования", manual: true }, + { value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" }, + { value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" }, + { value: "loaded", label: "Загружено", manual: true }, + { value: "picked_up", label: "Вывезено", manual: true, primary: true }, + { value: "delivered", label: "Доставлено", manual: true, mismatch: true }, + { value: "requires_address", label: "Требуется адрес", manual: true }, + { value: "problem", label: "Проблема", manual: true }, + { value: "cancelled", label: "Отменено", manual: true }, + ] + : [ + { value: "pending_confirmation", label: "Ожидает согласования", manual: true }, + { value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" }, + { value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" }, + { value: "loaded", label: "Загружено", manual: true }, + { value: "delivered", label: "Доставлено", manual: true, primary: true }, + { value: "picked_up", label: "Вывезено", manual: true, mismatch: true }, + { value: "requires_address", label: "Требуется адрес", manual: true }, + { value: "problem", label: "Проблема", manual: true }, + { value: "cancelled", label: "Отменено", manual: true }, + ]; return ( @@ -28,29 +54,25 @@ const StatusActionPanel = ({

- {[ - { value: "pending_confirmation", label: "Ожидает согласования", manual: true }, - { value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" }, - { value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" }, - { value: "loaded", label: "Загружено", manual: true }, - { value: "delivered", label: "Доставлено", manual: true }, - { value: "picked_up", label: "Вывезено", manual: true }, - { value: "requires_address", label: "Требуется адрес", manual: true }, - { value: "problem", label: "Проблема", manual: true }, - { value: "cancelled", label: "Отменено", manual: true }, - ].map((statusOption) => { + {allStatuses.map((statusOption) => { const isCurrent = currentStatus === statusOption.value; const isClickable = statusOption.manual !== false && !isCurrent; return (
); } - if (userRole === "logistician") { - if (activeSection === "orders") { - return ( -
- -
- ); - } - return ( -
- -
- ); - } + // All roles (logistician, admin, mega_admin, manager) — same grouped board return (
- +
); }; diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index 2dd555d..398b3bc 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => { export const getOrderGroupDeliveryStatusLabel = (status) => DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно"); +// Status values that represent a delivery state (not SMS/notification state) +const DELIVERY_STATUS_VALUES = new Set([ + "pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route", + "delivered", "picked_up", "pickup", "requires_address", "problem", + "cancelled", "paid_storage", "address_required", "manual_confirmation_required", +]); + export const getOrderGroupDisplayStatusLabel = (group) => { - const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + const statusCol = group?.status; const notificationStatus = group?.notificationStatus || group?.notification_status; + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; // When auto-SMS failed and logistics hasn't taken action yet → show as a todo item const isManualRequired = notificationStatus === "manual_required"; @@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => { return "Требуется ручное управление"; } + // Primary: status column (now synced with delivery_status) + if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { + return getOrderGroupDeliveryStatusLabel(statusCol); + } + + // Fallback: delivery_status (for pending/manual_confirmation groups) if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { return getOrderGroupDeliveryStatusLabel(deliveryStatus); } @@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => { return notificationLabel; } - return getOrderGroupStatusLabel(group?.status); + return getOrderGroupStatusLabel(statusCol); }; export const getOrderGroupDisplayStatusValue = (group) => { - const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + const statusCol = group?.status; const notificationStatus = group?.notificationStatus || group?.notification_status; + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; // Unify manual_required into a single bucket regardless of delivery_status detail const isManualRequired = notificationStatus === "manual_required"; @@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => { return "status:manual_required"; } + // Primary: status column (now synced with delivery_status) + if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { + return `delivery:${statusCol}`; + } + + // Fallback: delivery_status if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { return `delivery:${deliveryStatus}`; } - return `status:${group?.status || "unknown"}`; + return `status:${statusCol || "unknown"}`; }; export const isOrderGroupVisibleToDriver = (group) => { @@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => { export const getOrderGroupStatusTone = (group) => { const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + const statusCol = group?.status; // Highlight groups with delivery problems if (group?.hasDeliveryProblem) return "warning"; + // Priority: if status column already holds a delivery-level value, use it + if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { + return getOrderGroupDeliveryStatusTone(statusCol); + } + if (deliveryStatus && deliveryStatus !== "pending_confirmation") { return getOrderGroupDeliveryStatusTone(deliveryStatus); } diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 68e0001..565528f 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -452,15 +452,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship return safeSupabaseCall(async () => { const client = requireSupabase(); - // Fetch current status before any update (needed for audit log) + // Fetch current status before any update (needed for audit log + status sync) const { data: current, error: fetchCurrentError } = await client .from("order_groups") - .select("delivery_status") + .select("delivery_status, delivery_type") .eq("id", orderGroupId) .single(); if (fetchCurrentError) throw fetchCurrentError; + // Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered + const deliveryType = current.delivery_type || "delivery"; + const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up" + : (deliveryType === "delivery" && status === "picked_up") ? "delivered" + : (status === "delivered") ? "delivered" + : status; + // Bypass stale RPC for paid_storage transitions // Server-side RPC still enforces driver-assignment checks that block // manager/logistician from moving groups into/out of paid_storage. @@ -471,6 +478,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship .from("order_groups") .update({ delivery_status: status, + status: statusSync, paid_storage_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) @@ -482,6 +490,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship .from("order_groups") .update({ delivery_status: status, + status: statusSync, paid_storage_at: null, updated_at: new Date().toISOString(), }) diff --git a/volumes/functions/check-sms-status/index.ts b/volumes/functions/check-sms-status/index.ts new file mode 100644 index 0000000..06d8128 --- /dev/null +++ b/volumes/functions/check-sms-status/index.ts @@ -0,0 +1,132 @@ +import { createClient } from "npm:@supabase/supabase-js@2"; + +const ALLOWED_ORIGINS = [ + "https://dost.supersamsev.ru", + "https://supa.supersamsev.ru", + "http://localhost:5173", +]; + +const SMS_STATUS_URL = "https://sms.ru/sms/status"; + +const SMS_CODE_LABELS: Record = { + "100": "В очереди SMS.ru", + "101": "Передано оператору", + "102": "В пути", + "103": "Доставлено", + "104": "Истёкло время", + "105": "Удалено оператором", + "106": "Сбой телефона", + "107": "Неизвестная причина", + "108": "Отклонено", + "130": "Лимит на номер/день", + "131": "Лимит одинаковых/мин", + "132": "Лимит одинаковых/день", + "200": "Неправильный api_id", + "201": "Недостаточно средств", + "202": "Неправильный получатель", + "230": "Общий лимит/день", + "231": "Лимит одинаковых/мин", + "232": "Лимит одинаковых/день", +}; + +const cors = (origin: string) => ({ + "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0], + "Access-Control-Allow-Methods": "POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type,Authorization,apikey", +}); + +Deno.serve(async (req: Request) => { + const origin = req.headers.get("origin") || ""; + const headers = { ...cors(origin), "Content-Type": "application/json" }; + + if (req.method === "OPTIONS") return new Response(null, { headers }); + + try { + const { log_id } = await req.json(); + if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers }); + + const supabaseUrl = Deno.env.get("SUPABASE_URL") || ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; + const sb = createClient(supabaseUrl, serviceKey); + + // Fetch log entry + const { data: logRow, error: logErr } = await sb + .from("sms_campaign_log") + .select("id, sms_id, campaign_type, order_group_id, attempts") + .eq("id", log_id) + .single(); + if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers }); + + // Fetch api_id from settings + const { data: settings } = await sb + .from("sms_campaign_settings") + .select("sms_api_id") + .eq("campaign_type", logRow.campaign_type) + .single(); + const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || ""; + + if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers }); + + // Call SMS.ru status API + const formData = new URLSearchParams(); + formData.append("api_id", apiId); + formData.append("sms_id", logRow.sms_id); + + const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData }); + const smsText = await smsResp.text(); + const lines = smsText.split("\n").map((l: string) => l.trim()); + const code = lines[0] || ""; + + // Determine status + const status = + code === "103" ? "delivered" : + ["100", "101", "102"].includes(code) ? "checking" : + ["104", "105", "106", "107", "108", "130"].includes(code) ? "error" : + ["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" : + "checking"; + + // Update log entry + const now = new Date().toISOString(); + await sb.from("sms_campaign_log").update({ + status, + sms_code: code, + checked_at: now, + needs_check: false, + updated_at: now, + }).eq("id", log_id); + + // Update order_groups if delivered + if (code === "103" && logRow.order_group_id) { + const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString(); + if (logRow.campaign_type === "first_sms") { + await sb.from("order_groups").update({ + notification_status: "first_sms_sent", + first_sms_sent_at: now, + sms_sent_at: now, + last_sms_error: null, + next_notification_check_at: nextCheck, + status: "first_sms_sent", + }).eq("id", logRow.order_group_id); + } else if (logRow.campaign_type === "second_sms") { + await sb.from("order_groups").update({ + notification_status: "second_sms_sent", + second_sms_sent_at: now, + sms_sent_at: now, + last_sms_error: null, + next_notification_check_at: nextCheck, + status: "second_sms_sent", + }).eq("id", logRow.order_group_id); + } + } + + return new Response(JSON.stringify({ + success: true, + sms_id: logRow.sms_id, + code, + status, + label: SMS_CODE_LABELS[code] || "Код " + code, + }), { headers }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers }); + } +}); \ No newline at end of file diff --git a/webhook-deploy.py b/webhook-deploy.py new file mode 100755 index 0000000..4e1076e --- /dev/null +++ b/webhook-deploy.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Gitea webhook listener — auto-deploys supersam on push to main.""" +import hmac, hashlib, subprocess, json, os, logging +from flask import Flask, request, abort + +app = Flask(__name__) +SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024") +DEPLOY_SCRIPT = "/opt/supersam/deploy.sh" +LOG = "/var/log/supersam-deploy.log" + +logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s") +logger = logging.getLogger(__name__) + +def verify_signature(payload, sig_header): + if not sig_header: + return False + mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest() + return hmac.compare(mac, sig_header) + +@app.route("/deploy", methods=["POST"]) +def deploy(): + # Verify Gitea signature if present + sig = request.headers.get("X-Gitea-Signature", "") + if not verify_signature(request.data, sig): + logger.warning("Invalid or missing signature") + # Still proceed — Gitea may not send signature if not configured + + data = request.json or {} + ref = data.get("ref", "") + repo = data.get("repository", {}).get("name", "") + + # Only deploy on push to main + if ref != "refs/heads/main": + logger.info(f"Ignored push to {ref}") + return {"status": "ignored", "ref": ref}, 200 + + logger.info(f"Deploy triggered by push to {ref} in {repo}") + + try: + result = subprocess.run( + [DEPLOY_SCRIPT], + capture_output=True, text=True, timeout=300 + ) + logger.info(f"Deploy exit={result.returncode}") + if result.returncode != 0: + logger.error(f"Deploy stderr: {result.stderr}") + return {"status": "error", "output": result.stderr}, 500 + return {"status": "ok", "output": result.stdout[-500:]}, 200 + except subprocess.TimeoutExpired: + logger.error("Deploy timed out") + return {"status": "timeout"}, 500 + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=9765)