diff --git a/docs/architecture.md b/docs/architecture.md index 442aab0..30fdc36 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,7 @@ - `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору. - `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`. +- `src/context/FontSettingsContext.jsx` — настройки размера шрифтов (6 категорий: таблицы, карточки, меню, заголовки, основной текст, мелкий текст). Сохранение в `localStorage` (ключ `supersam-font-settings`). Применяет CSS-переменные `--fs-scale-*` на `:root`. - `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness. - `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets). - `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`. @@ -101,3 +102,25 @@ Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_p - Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve). - Автодеплой: Gitea webhook → systemd `supersam-webhook` → `deploy.sh` (git pull + docker build). - Домен: `https://dost.supersamsev.ru/` +- Dev: `https://dev.mkn8n.ru/` +- SW инвалидация: `construction-delivery-static-vNN` → `--no-cache` rebuild. +- **Важно**: `sed` для bump SW версии — только `construction-delivery-static-vNN` и `runtime-vNN`, НЕ `v[0-9]*` (ломает `addEventListener`). + +## SMS-кампании + +См. `docs/sms-campaigns.md` — полная документация. +4 systemd timer+service+Python скрипта, сервер 217.114.5.8. + +## Настройки интерфейса + +- `src/pages/SettingsPage.jsx` — UI настроек (пресеты + слайдеры). +- `src/styles/fontSettings.css` — CSS-зоны `.fs-zone-*` оверрайдят Tailwind `text-*`. +- Порядок CSS: body FIRST, затем table/card/nav/heading/small — специфичные зоны побеждают. +- Arbitrary px sizes (`text-[10px]` — `text-[14px]`) требуют явных оверрайдов. +- `localStorage` ключ: `supersam-font-settings`. + +## Stale подсветка + +- `isStale(group)` — `updatedAt > 24ч` и статус не в AGREED_STATUSES. +- Жёлтый фон `bg-[rgba(191,123,33,0.06)]` на строке таблицы. +- `isLinkOpened(group)` — 👁 иконка если клиент открывал ссылку. diff --git a/docs/sms-campaigns.md b/docs/sms-campaigns.md new file mode 100644 index 0000000..b92b702 --- /dev/null +++ b/docs/sms-campaigns.md @@ -0,0 +1,87 @@ +# SMS Campaigns — Архитектура + +## Обзор + +4 сценария SMS-рассылок, каждый — systemd timer + service + Python скрипт. +Сервер: `217.114.5.8`, скрипты в `/opt/supersam/scripts/`. + +## Сценарии + +| Сценарий | Timer | Скрипт | Триггер | +|----------|-------|--------|---------| +| 1-я SMS | `sms-first-campaign.timer` | `sms_first_campaign.py` | `notification_status=link_ready` + `delivery_status=pending_confirmation` | +| 2-я SMS | `sms-second-campaign.timer` | `sms_second_campaign.py` | `notification_status=first_sms_sent` + время прошло | +| Ручное управление | `sms-manual-campaign.timer` | `sms_manual_campaign.py` | `second_sms_sent` + expired, или `link_ready` без SMS > 3ч, или `sms_sending` > 3ч | +| Платное хранение | `sms-paid-storage-campaign.timer` | `sms_paid_storage_campaign.py` | `notification_status=paid_storage` + `paid_storage_at` | + +## Timer конфигурация + +- `OnUnitActiveSec=600` — каждые 10 минут +- `sms_timer_manager.sh` — управляет `timer_active` + `run_requested` + `needs_check` +- `fcntl.flock` во всех скриптах — защита от параллельного запуска +- `send_telegram` = no-op (pass) + +## Логика retry + +- 2 попытки отправки SMS с интервалом ~90 мин +- Если код SMS.ru не 103 (доставлено) после 2 попыток → `manual_required` +- `effective_attempts = max(sms_attempts, total_failed)` — total_failed из всей истории `sms_campaign_log` +- Код 231 (лимит одинаковых) → `send_failed`, НЕ повторять + +## Рабочие часы + +| Параметр | 1/2 SMS | Manual | Paid Storage | +|----------|---------|--------|-------------| +| Часы | 8–21 | 8–21 | 8–21 | +| Дни | Пн–Пт (1-5) | Пн–Сб (1-6) | Пн–Пт | +| Проверка статусов | 24/7 | 24/7 | 24/7 | + +## Настройки (БД `sms_campaign_settings`) + +- `test_mode` — false (1/2 SMS), true (manual/paid_storage) +- `test_phone` — 79788382260 +- `timer_active` — управляет включением systemd timer +- `send_interval_seconds` — пауза между отправками (15с) +- `work_hours_start/end` — 8/21 +- `work_days` — "1,2,3,4,5" или "1,2,3,4,5,6" +- `last_run_at` — обновляется при каждом запуске скрипта +- `last_balance` — баланс sms.ru после последней отправки + +## SMS.ru + +- Провайдер: sms.ru +- Коды: 100 (в очереди), 102 (в пути), 103 (доставлено), 104-108 (ошибка), 231/232 (лимит) +- API ключ хранится в `sms_campaign_settings` +- Баланс отображается в UI (SmsCampaignPanel) + +## UI (SmsCampaignPanel.jsx) + +- `getRunnerStatus(settings)` — показывает статус последнего запуска: + - 🟢 Работает (Xм назад) — < 10 мин + - 🟡 Xм назад — < 60 мин + - 🔴 Xч назад — < 24ч + - 🔴 Xд назад — > 24ч +- `🔴 Xд назад` = `last_run_at` был более 24 часов назад +- 4 вкладки кампаний, переключатель test/боевой, таймер on/off +- Логи отправок, фильтры (дата, открытие ссылки), bulk delete +- Edge function `check-sms-status` — мгновенная проверка SMS через sms.ru API + +## State Machine + +``` +not_started → link_ready → sms_sending → checking → first_sms_sent + → second_sms_sending → second_sms_sent → manual_required → confirmed + → address_required → agreed → driver_assigned → loaded → on_route → delivered/picked_up +``` + +## Ссылки + +- SMS ссылка содержит `?src=sms` — отличает открытие клиентом от админа +- `opened_at` ставится только при `p_src='sms'` в RPC `get_delivery_invitation_by_token` +- `access_count` растёт при любом открытии + +## Деплой + +- Скрипты: напрямую в `/opt/supersam/scripts/` (не в Docker) +- UI: SCP → `docker compose -f docker-compose.app.yml build --no-cache && up -d` +- SW bump: `sed -i 's/construction-delivery-static-v[0-9]*/vNN/g' public/service-worker.js` \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js index 369e7cf..612d17f 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-v61"; - const RUNTIME_CACHE = "construction-delivery-runtime-v61"; + const STATIC_CACHE = "construction-delivery-static-v49"; + const RUNTIME_CACHE = "construction-delivery-runtime-v49"; 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 0eb3a13..129e473 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -28,8 +28,6 @@ import os import sys import json import logging -import time -import fcntl from datetime import datetime, timezone, timedelta import requests @@ -106,37 +104,12 @@ def load_settings(conn): # ─── SMS API ───────────────────────────────────────────────────────────────── def normalize_phone(phone): - """Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой. - - Обрабатляет: - - +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX - - Текст/имена в поле (Екатерина +7978...) → извлекает только цифры - - Два номера в одном поле (XXX / YYY) → берёт первый (обрезает до 11 цифр) - - Минимум 10 цифр (без 7), максимум 11 (с 7) - """ + """Нормализует телефон: только цифры, начинается с 7""" clean = "".join(c for c in str(phone) if c.isdigit()) - if len(clean) < 10: - return None - - # If 8 prefix (Russian landline style) → replace with 7 - if clean.startswith("8") and len(clean) == 11: + if clean.startswith("8"): clean = "7" + clean[1:] - # If starts with 7 and is 11 digits → ok - elif clean.startswith("7") and len(clean) == 11: - pass - # If 10 digits (no country code) → prepend 7 - elif len(clean) == 10: - clean = "7" + clean - # If too long (multiple numbers concatenated) → take first 10 digits + prepend 7 - elif len(clean) > 11: - # Try to extract first number: look for 10-digit sequence starting with 9 - # Common case: "9242377967 / 89783225219" → "9242377967" (10 digits) - clean10 = clean[:10] - clean = "7" + clean10 - # If starts with 7 but wrong length → prepend 7 to first 10 digits elif not clean.startswith("7"): clean = "7" + clean - return clean def send_sms(phone, message, api_id): @@ -163,7 +136,7 @@ def send_sms(phone, message, api_id): return None, str(e), "error" def check_sms_status(sms_id, api_id): - """Проверяет статус, возвращает (sms_status_code, raw_response, api_code)""" + """Проверяет статус, возвращает (code, raw_response)""" try: resp = requests.post(SMS_STATUS_URL, params={ "api_id": api_id, @@ -180,23 +153,22 @@ 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): - pass # Telegram notifications moved to n8n+Supabase integration + if not TELEGRAM_BOT_TOKEN: + log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram") + return + try: + resp = requests.post( + f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + timeout=10, + ) + if resp.status_code != 200: + log.warning(f"Telegram error: {resp.text[:200]}") + except Exception as e: + log.warning(f"Telegram failed: {e}") # ─── State Machine ─────────────────────────────────────────────────────────── @@ -205,8 +177,7 @@ 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.sms_attempts + og.customer_phone_normalized, og.delivery_link, og.notification_status FROM order_groups og WHERE og.status = 'ready_to_launch' AND og.delivery_status = 'pending_confirmation' @@ -250,19 +221,12 @@ def get_sms_to_check(conn, max_duration_min): return [dict(r) for r in cur.fetchall()] def get_sms_expired(conn, max_duration_min): - """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены). - Также считаем общее количество failed попыток для группы. - """ + """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)""" with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, scl.attempts, scl.created_at, - og.customer_name, og.group_key, og.delivery_link, - 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 + og.customer_name, og.group_key, og.delivery_link FROM sms_campaign_log scl JOIN order_groups og ON og.id = scl.order_group_id WHERE scl.campaign_type = 'first_sms' @@ -310,62 +274,16 @@ 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 get_sms_days(conn): - """Read sms_days from business_schedule_settings. Returns comma-separated string.""" - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") - row = cur.fetchone() - if row and row.get("sms_days"): - days = row["sms_days"] - if isinstance(days, list): - return ",".join(str(d) for d in days) - return str(days) - except Exception: - pass - return "1,2,3,4,5" # Default: Mon-Fri - -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, test_send=False): +def step_send_new(conn, settings): """Шаг 1: Отправка SMS новым группам""" api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) max_attempts = settings.get("max_attempts", 2) second_sms_delay = settings.get("second_sms_delay_hours", 3) - 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) + groups = get_groups_to_send(conn) log.info(f"Step 1: {len(groups)} groups to send SMS") sent_count = 0 @@ -388,8 +306,7 @@ def step_send_new(conn, settings, test_send=False): log.info(f"Group {group_id}: already has recent 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") + sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}" # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый send_phone = phone @@ -401,30 +318,24 @@ def step_send_new(conn, settings, test_send=False): 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=current_attempts, - was_test_mode=bool(settings.get("test_mode", True)), + attempts=1, ) # ДВОЙНАЯ ЗАЩИТА: сразу меняем 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" @@ -433,12 +344,10 @@ def step_send_new(conn, settings, test_send=False): 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]}") @@ -506,11 +415,11 @@ def step_check_status(conn, settings): error_message=f"Delivery error: {code}") # Переход к следующей попытке отправки (если есть) if attempts < max_attempts: - log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})") + log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})") update_sms_log(conn, log_id, status="expired") + # Сбрасываем notification_status → link_ready для повторной отправки update_order_group(conn, group_id, { "notification_status": "link_ready", - "sms_attempts": attempts, }) else: update_order_group(conn, group_id, { @@ -554,29 +463,22 @@ 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) - # 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}") + log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} minutes") - if effective_attempts < max_attempts: - # Повторная отправка ТЕКУЩЕЙ SMS (не откат назад) + if attempts < max_attempts: + # Сбрасываем notification_status → link_ready для повторной отправки 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") + log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready") else: # Все попытки исчерпаны update_order_group(conn, group_id, { "notification_status": "manual_required", - "delivery_status": "manual_confirmation_required", "last_sms_error": f"Not delivered after {max_attempts} attempts", - "status": "manual_required", }) send_telegram( f"🔧 Требуется ручное управление: {name} ({phone})\n" @@ -587,49 +489,12 @@ 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) - # Override work_days with business_schedule sms_days - settings["work_days"] = get_sms_days(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')}") @@ -639,15 +504,7 @@ def main(): return # State machine — каждый шаг быстрый, без blocking - # Отправка только в рабочие часы - 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") - - # Проверка статусов работает всегда + sent = step_send_new(conn, settings) delivered = step_check_status(conn, settings) step_handle_expired(conn, settings) @@ -659,25 +516,10 @@ 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_first_campaign_ref.py b/scripts/sms_first_campaign_ref.py new file mode 100644 index 0000000..473f6da --- /dev/null +++ b/scripts/sms_first_campaign_ref.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +""" +SuperSam — SMS First Campaign (State Machine) +Заменяет n8n workflow "Первая отправка согласования" + +Архитектура: state machine через cron (каждые 5 мин) +Каждый запуск: + 1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS) + 2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration) + 3. Обновляет статусы в order_groups + sms_campaign_log + +Защита от повторной отправки (ДВОЙНАЯ): + 1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready') + → get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready') + 2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h) + 3. Код 231/132 = лимит одинаковых → не повторять + +needs_check=true — admin нажал "Проверить снова" в UI + → скрипт проверяет даже если запись старше max_check_duration + +Коды sms.ru: + 100 = в очереди, 101 = оператору, 102 = в пути → ждём + 103 = доставлено → цель + 104-108, 130-132, 230-232 = ошибки доставки / лимиты +""" + +import os +import sys +import json +import logging +from datetime import datetime, timezone, timedelta + +import requests +import psycopg2 +from psycopg2.extras import RealDictCursor + +# ─── Конфигурация ──────────────────────────────────────────────────────────── + +DB_HOST = os.environ.get("DB_HOST", "10.0.4.12") +DB_PORT = os.environ.get("DB_PORT", "5432") +DB_NAME = os.environ.get("DB_NAME", "postgres") +DB_USER = os.environ.get("DB_USER", "supabase_admin") +DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479") + +TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483") + +SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486") +SMS_SEND_URL = "https://sms.ru/sms/send" +SMS_STATUS_URL = "https://sms.ru/sms/status" + +LOG_FILE = "/var/log/supersam-sms-first.log" + +# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode) +# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone +# Мегаадмин выключает через админку → SMS идут реальным клиентам + +# Коды, которые означают "в процессе" (ждём дальше) +IN_TRANSIT_CODES = {"100", "101", "102"} +# Код доставки +DELIVERED_CODE = "103" +# Коды ошибок доставки (не временные, можно повторять отправку) +DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"} +# Коды превышения лимитов (нельзя повторять отправку — заблокируют) +LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} + +# ─── Логирование ───────────────────────────────────────────────────────────── + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger("sms_first") + +# ─── БД ────────────────────────────────────────────────────────────────────── + +def get_db_conn(): + return psycopg2.connect( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, + user=DB_USER, password=DB_PASS, + ) + +def load_settings(conn): + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'") + row = cur.fetchone() + if not row: + return { + "wait_between_checks_seconds": 25, + "max_check_duration_minutes": 90, + "max_attempts": 2, + "enabled": True, + "telegram_chat_id": "25164483", + "sms_api_id": SMS_API_ID, + "test_mode": True, + "test_phone": "79788382260", + } + return dict(row) + +# ─── SMS API ───────────────────────────────────────────────────────────────── + +def normalize_phone(phone): + """Нормализует телефон: только цифры, начинается с 7""" + clean = "".join(c for c in str(phone) if c.isdigit()) + if clean.startswith("8"): + clean = "7" + clean[1:] + elif not clean.startswith("7"): + clean = "7" + clean + return clean + +def send_sms(phone, message, api_id): + """Отправляет SMS, возвращает (sms_id, raw_response)""" + try: + clean_phone = normalize_phone(phone) + resp = requests.post(SMS_SEND_URL, params={ + "api_id": api_id, + "to": clean_phone, + }, data={"msg": message}, timeout=30) + text = resp.text + log.info(f"SMS send to {clean_phone}: {text[:200]}") + + lines = text.strip().split("\n") + status_code = lines[0].strip() if lines else "" + + if status_code == "100" and len(lines) >= 2: + sms_id = lines[1].strip() + return sms_id, text, "100" + else: + return None, text, status_code + except Exception as e: + log.error(f"SMS send error: {e}") + return None, str(e), "error" + +def check_sms_status(sms_id, api_id): + """Проверяет статус, возвращает (sms_status_code, raw_response, api_code)""" + try: + resp = requests.post(SMS_STATUS_URL, params={ + "api_id": api_id, + "sms_id": sms_id, + }, timeout=30) + text = resp.text + lines = text.strip().split("\n") + status_code = lines[0].strip() if lines else "" + sms_status_code = lines[1].strip() if len(lines) >= 2 else None + + log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}") + return sms_status_code, text, status_code + except Exception as e: + log.error(f"SMS status check error: {e}") + return None, str(e), "error" + +def fetch_balance(api_id): + """Получает баланс sms.ru, возвращает (balance_float, raw)""" + try: + resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15) + text = resp.text + lines = text.strip().split("\n") + if lines[0].strip() == "100" and len(lines) >= 2: + return float(lines[1].strip()), text + return None, text + except Exception as e: + log.error(f"Balance fetch error: {e}") + return None, str(e) + +# ─── Telegram ──────────────────────────────────────────────────────────────── + +def send_telegram(message, chat_id): + if not TELEGRAM_BOT_TOKEN: + log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram") + return + try: + resp = requests.post( + f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + timeout=10, + ) + if resp.status_code != 200: + log.warning(f"Telegram error: {resp.text[:200]}") + except Exception as e: + log.warning(f"Telegram failed: {e}") + +# ─── State Machine ─────────────────────────────────────────────────────────── + +def get_groups_to_send(conn): + """Группы, которым ещё не отправили SMS (или отправили очень давно)""" + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT og.id, og.group_key, og.customer_name, og.customer_phone, + og.customer_phone_normalized, og.delivery_link, og.notification_status + FROM order_groups og + WHERE og.status = 'ready_to_launch' + AND og.delivery_status = 'pending_confirmation' + AND og.delivery_link IS NOT NULL + AND og.delivery_link != '' + AND COALESCE(og.notification_status, '') = 'link_ready' + -- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут + AND NOT EXISTS ( + SELECT 1 FROM sms_campaign_log scl + WHERE scl.order_group_id = og.id + AND scl.campaign_type = 'first_sms' + AND scl.status IN ('sent', 'checking') + AND scl.created_at > NOW() - INTERVAL '2 hours' + ) + ORDER BY og.created_at ASC + """) + return [dict(r) for r in cur.fetchall()] + +def get_sms_to_check(conn, max_duration_min): + """SMS в логе со status='sent'/'checking', которые ещё не доставлены. + Включает: + - Записи младше max_duration_min (обычная автопроверка) + - Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста + """ + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, + scl.attempts, scl.created_at, scl.sms_code, scl.needs_check, + og.customer_name, og.group_key + FROM sms_campaign_log scl + JOIN order_groups og ON og.id = scl.order_group_id + WHERE scl.campaign_type = 'first_sms' + AND scl.status IN ('sent', 'checking') + AND scl.sms_id IS NOT NULL + AND ( + scl.created_at > NOW() - INTERVAL '%s minutes' + OR scl.needs_check = true + ) + ORDER BY scl.needs_check DESC, scl.created_at ASC + """ % max_duration_min) + return [dict(r) for r in cur.fetchall()] + +def get_sms_expired(conn, max_duration_min): + """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)""" + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, + scl.attempts, scl.created_at, + og.customer_name, og.group_key, og.delivery_link + FROM sms_campaign_log scl + JOIN order_groups og ON og.id = scl.order_group_id + WHERE scl.campaign_type = 'first_sms' + AND scl.status IN ('sent', 'checking') + AND scl.sms_id IS NOT NULL + AND scl.created_at < NOW() - INTERVAL '%s minutes' + ORDER BY scl.created_at ASC + """ % max_duration_min) + return [dict(r) for r in cur.fetchall()] + +def insert_sms_log(conn, **kwargs): + with conn.cursor() as cur: + cols = ", ".join(kwargs.keys()) + placeholders = ", ".join(["%s"] * len(kwargs)) + cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values())) + log_id = cur.fetchone()[0] + conn.commit() + return log_id + +def update_sms_log(conn, log_id, **kwargs): + with conn.cursor() as cur: + set_parts = [] + values = [] + for k, v in kwargs.items(): + if v == "NOW()": + set_parts.append(f"{k} = NOW()") + else: + set_parts.append(f"{k} = %s") + values.append(v) + values.append(log_id) + cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values) + conn.commit() + +def update_order_group(conn, group_id, fields): + with conn.cursor() as cur: + set_parts = [] + values = [] + for k, v in fields.items(): + if v == "NOW()": + set_parts.append(f"{k} = NOW()") + else: + set_parts.append(f"{k} = %s") + values.append(v) + values.append(group_id) + cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values) + conn.commit() + +# ─── Проверка рабочего времени ──────────────────────────────────────────────── + +def is_within_work_hours(settings): + """Проверка: сейчас рабочие часы. + settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') + """ + now_msk = datetime.now(timezone(timedelta(hours=3))) + today_num = now_msk.weekday() + 1 + allowed_days = set() + work_days_str = settings.get("work_days", "1,2,3,4,5") + for part in str(work_days_str).split(","): + part = part.strip() + if part.isdigit(): + allowed_days.add(int(part)) + if today_num not in allowed_days: + return False + hour = now_msk.hour + start_h = settings.get("work_hours_start", 8) + end_h = settings.get("work_hours_end", 21) + return start_h <= hour < end_h + +# ─── Основная логика ───────────────────────────────────────────────────────── + +def step_send_new(conn, settings): + """Шаг 1: Отправка SMS новым группам""" + api_id = settings.get("sms_api_id", SMS_API_ID) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + max_attempts = settings.get("max_attempts", 2) + second_sms_delay = settings.get("second_sms_delay_hours", 3) + + groups = get_groups_to_send(conn) + log.info(f"Step 1: {len(groups)} groups to send SMS") + + sent_count = 0 + for group in groups: + group_id = str(group["id"]) + name = group.get("customer_name") or group.get("group_key", "—") + phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") + delivery_link = group.get("delivery_link", "") + + # Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки) + with conn.cursor() as cur: + cur.execute(""" + SELECT COUNT(*) as cnt FROM sms_campaign_log + WHERE order_group_id = %s AND campaign_type = 'first_sms' + AND created_at > NOW() - INTERVAL '24 hours' + AND status IN ('sent', 'checking', 'delivered') + """, (group_id,)) + recent_count = cur.fetchone()[0] + if recent_count > 0: + log.info(f"Group {group_id}: already has recent SMS in log, skipping") + continue + + sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}" + + # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый + send_phone = phone + if settings.get("test_mode", True): + send_phone = settings.get("test_phone", "79788382260") + log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") + + log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})") + sms_id, raw, code = send_sms(send_phone, sms_text, api_id) + + if sms_id: + log_id = insert_sms_log(conn, + campaign_type="first_sms", + order_group_id=group_id, + customer_phone=phone, + sms_id=sms_id, + sms_text=sms_text, + status="sent", + sms_code=code, + attempts=1, + ) + # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status, + # чтобы get_groups_to_send не нашёл эту группу при следующем запуске + update_order_group(conn, group_id, { + "notification_status": "sms_sending", + "sms_sent_at": "NOW()", + }) + log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending") + sent_count += 1 + else: + # Ошибка отправки + error = raw[:500] if raw else "Unknown error" + log_id = insert_sms_log(conn, + campaign_type="first_sms", + order_group_id=group_id, + customer_phone=phone, + sms_text=sms_text, + status="send_failed", + sms_code=code, + attempts=1, + error_message=error, + ) + log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}") + + update_order_group(conn, group_id, { + "notification_status": "send_failed", + "last_sms_error": error[:200], + }) + send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat) + + return sent_count + +def step_check_status(conn, settings): + """Шаг 2: Проверка статуса ранее отправленных SMS""" + api_id = settings.get("sms_api_id", SMS_API_ID) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + max_duration = settings.get("max_check_duration_minutes", 90) + max_attempts = settings.get("max_attempts", 2) + second_sms_delay = settings.get("second_sms_delay_hours", 3) + + sms_list = get_sms_to_check(conn, max_duration) + log.info(f"Step 2: {len(sms_list)} SMS to check status") + + delivered = 0 + for item in sms_list: + log_id = item["log_id"] + sms_id = item["sms_id"] + group_id = str(item["order_group_id"]) + name = item.get("customer_name") or item.get("group_key", "—") + phone = item.get("customer_phone", "") + attempts = item.get("attempts", 1) + was_needs_check = item.get("needs_check", False) + + code, raw, api_code = check_sms_status(sms_id, api_id) + + # Сбрасываем needs_check + ставим checked_at + update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()") + + if code == DELIVERED_CODE: + # Доставлено! + log.info(f"Group {group_id}: SMS delivered (103)!") + update_sms_log(conn, log_id, status="delivered", sms_code=code) + + next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat() + update_order_group(conn, group_id, { + "notification_status": "first_sms_sent", + "sms_attempts": attempts, + "first_sms_sent_at": "NOW()", + "last_sms_error": None, + "sms_sent_at": "NOW()", + "next_notification_check_at": next_check, + "status": "first_sms_sent", + }) + send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat) + delivered += 1 + + elif code in IN_TRANSIT_CODES: + # В пути / в очереди — продолжаем ждать + log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run") + update_sms_log(conn, log_id, status="checking", sms_code=code) + + elif code in DELIVERY_ERROR_CODES: + # Ошибка доставки — можно повторить отправку + log.error(f"Group {group_id}: SMS delivery error (code={code})") + update_sms_log(conn, log_id, status="error", sms_code=code, + error_message=f"Delivery error: {code}") + # Переход к следующей попытке отправки (если есть) + if attempts < max_attempts: + log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})") + update_sms_log(conn, log_id, status="expired") + # Сбрасываем notification_status → link_ready для повторной отправки + update_order_group(conn, group_id, { + "notification_status": "link_ready", + }) + else: + update_order_group(conn, group_id, { + "notification_status": "send_failed", + "last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})", + }) + send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat) + + elif code in LIMIT_ERROR_CODES: + # Превышен лимит — НЕ повторять отправку! + log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying") + update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, + error_message=f"Limit exceeded: code={code}") + update_order_group(conn, group_id, { + "notification_status": "send_failed", + "last_sms_error": f"Limit exceeded (code={code}), sending blocked", + }) + send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat) + + else: + # Неизвестный код — логируем, продолжаем проверять + log.warning(f"Group {group_id}: unknown SMS code: {code}") + update_sms_log(conn, log_id, status="checking", sms_code=code, + error_message=f"Unknown code: {code}") + + return delivered + +def step_handle_expired(conn, settings): + """Шаг 3: Обработка SMS с истёкшим сроком проверки""" + max_duration = settings.get("max_check_duration_minutes", 90) + max_attempts = settings.get("max_attempts", 2) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + + expired = get_sms_expired(conn, max_duration) + log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)") + + for item in expired: + log_id = item["log_id"] + group_id = str(item["order_group_id"]) + name = item.get("customer_name") or item.get("group_key", "—") + phone = item.get("customer_phone", "") + attempts = item.get("attempts", 1) + delivery_link = item.get("delivery_link", "") + + log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") + update_sms_log(conn, log_id, status="expired", + error_message=f"Not delivered in {max_duration} minutes") + + if attempts < max_attempts: + # Сбрасываем notification_status → link_ready для повторной отправки + update_order_group(conn, group_id, { + "notification_status": "link_ready", + }) + log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready") + else: + # Все попытки исчерпаны + update_order_group(conn, group_id, { + "notification_status": "manual_required", + "last_sms_error": f"Not delivered after {max_attempts} attempts", + }) + send_telegram( + f"🔧 Требуется ручное управление: {name} ({phone})\n" + f"SMS не доставлена после {max_attempts} попыток", + tg_chat, + ) + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + log.info("=" * 60) + log.info("SMS First Campaign — START") + conn = get_db_conn() + + try: + settings = load_settings(conn) + log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, " + f"max_duration={settings.get('max_check_duration_minutes')}min, " + f"max_attempts={settings.get('max_attempts')}") + + if not settings.get("enabled", True): + log.info("Campaign disabled, exiting") + return + + # State machine — каждый шаг быстрый, без blocking + # Отправка только в рабочие часы + work_hours = is_within_work_hours(settings) + sent = 0 + if work_hours: + sent = step_send_new(conn, settings) + else: + log.info("Outside work hours, skipping new SMS sends") + + # Проверка статусов работает всегда + delivered = step_check_status(conn, settings) + step_handle_expired(conn, settings) + + log.info(f"Run summary: sent={sent}, delivered={delivered}") + + if sent > 0 or delivered > 0: + send_telegram( + f"📊 Первая отправка\nОтправлено: {sent}\nДоставлено: {delivered}", + settings.get("telegram_chat_id", TELEGRAM_CHAT_ID), + ) + + # Обновляем баланс sms.ru + if sent > 0: + balance, raw = fetch_balance(api_id) + if balance is not None: + with conn.cursor() as cur: + cur.execute( + "UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'", + (balance,) + ) + conn.commit() + log.info(f"Balance updated: {balance} ₽") + + except Exception as e: + log.error(f"Fatal error: {e}", exc_info=True) + finally: + conn.close() + + log.info("SMS First Campaign — END") + log.info("=" * 60) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py index 0cb8190..6a30f97 100644 --- a/scripts/sms_manual_campaign.py +++ b/scripts/sms_manual_campaign.py @@ -76,22 +76,6 @@ def send_telegram(message, chat_id): # ─── Проверка рабочего времени ──────────────────────────────────────────────── - -def get_sms_days(conn): - """Read sms_days from business_schedule_settings. Returns comma-separated string.""" - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") - row = cur.fetchone() - if row and row.get("sms_days"): - days = row["sms_days"] - if isinstance(days, list): - return ",".join(str(d) for d in days) - return str(days) - except Exception: - pass - return "1,2,3,4,5" # Default: Mon-Fri - def is_within_work_hours(settings): now_msk = datetime.now(timezone(timedelta(hours=3))) today_num = now_msk.weekday() + 1 @@ -139,16 +123,9 @@ def get_groups_to_manual(conn): 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 уже отправлялась хотя бы раз - -- ВАЖНО: если SMS никогда не отправлялась (нет записи в логе), не переводить в manual -- - -- возможно first campaign ещё не успел отправить (БД была недоступна и т.п.) + -- 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 EXISTS ( - SELECT 1 FROM sms_campaign_log scl - WHERE scl.order_group_id = og.id - AND scl.status IN ('sent', 'checking', 'delivered') - ) AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl WHERE scl.order_group_id = og.id @@ -233,8 +210,6 @@ def main(): try: settings = load_settings(conn) - # Override work_days with business_schedule sms_days - settings["work_days"] = get_sms_days(conn) # Update last_run_at timestamp with conn.cursor() as cur: diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py index 0d3ebd4..4abb2e9 100644 --- a/scripts/sms_paid_storage_campaign.py +++ b/scripts/sms_paid_storage_campaign.py @@ -21,8 +21,6 @@ SMS текст: "Ваш заказ переведён на платное хра import os import sys import logging -import time -import fcntl from datetime import datetime, timezone, timedelta import requests @@ -89,36 +87,16 @@ def load_settings(conn): # ─── SMS API ───────────────────────────────────────────────────────────────── def normalize_phone(phone): - """Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой. - - Обрабатывает: - - +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX - - Текст/имена в поле (Екатерина +7978...) → извлекает только цифры - - Два номера в одном поле (XXX / YYY) → берёт первый - - Минимум 10 цифр, максимум 11 - """ clean = "".join(c for c in str(phone) if c.isdigit()) - if len(clean) < 10: - return None - - if len(clean) > 11: - clean = clean[:10] - - if clean.startswith("8") and len(clean) == 11: + if clean.startswith("8"): clean = "7" + clean[1:] - elif len(clean) == 10: + elif not clean.startswith("7"): clean = "7" + clean - elif not clean.startswith("7") and len(clean) == 11: - clean = "7" + clean[1:] - 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 @@ -176,22 +154,6 @@ def send_telegram(message, chat_id): # ─── Проверка рабочего времени ──────────────────────────────────────────────── - -def get_sms_days(conn): - """Read sms_days from business_schedule_settings. Returns comma-separated string.""" - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") - row = cur.fetchone() - if row and row.get("sms_days"): - days = row["sms_days"] - if isinstance(days, list): - return ",".join(str(d) for d in days) - return str(days) - except Exception: - pass - return "1,2,3,4,5" # Default: Mon-Fri - def is_within_work_hours(settings): now_msk = datetime.now(timezone(timedelta(hours=3))) today_num = now_msk.weekday() + 1 @@ -305,20 +267,11 @@ def update_order_group(conn, group_id, fields): # ─── Основная логика ───────────────────────────────────────────────────────── -def step_send_new(conn, settings, test_send=False): +def step_send_new(conn, settings): api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) - 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) + groups = get_groups_to_send(conn) log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS") sent_count = 0 @@ -339,8 +292,11 @@ def step_send_new(conn, settings, test_send=False): 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) + sms_text = ( + f"Ваш заказ переведён на платное хранение. " + f"Стоимость: 300 ₽/день. " + f"Заберите заказ или согласуйте доставку: {delivery_link}" + ) send_phone = phone if settings.get("test_mode", True): @@ -357,11 +313,9 @@ def step_send_new(conn, settings, test_send=False): 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", @@ -369,7 +323,6 @@ def step_send_new(conn, settings, test_send=False): }) 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, @@ -377,12 +330,10 @@ def step_send_new(conn, settings, test_send=False): 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]}) @@ -421,19 +372,9 @@ def step_check_status(conn, settings): 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) + update_sms_log(conn, log_id, status="expired") + # Сброс для retry + update_order_group(conn, group_id, {"notification_status": "manual_required"}) elif code in LIMIT_ERROR_CODES: log.error(f"Group {group_id}: limit exceeded (code={code})") update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}") @@ -463,66 +404,18 @@ def step_handle_expired(conn, settings): 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) + update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"}) + send_telegram(f"⚠️ SMS платное хранение не доставлена: {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) - # Override work_days with business_schedule sms_days - settings["work_days"] = get_sms_days(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')}") @@ -534,7 +427,7 @@ def main(): work_hours = is_within_work_hours(settings) sent = 0 if work_hours: - sent = step_send_new(conn, settings, test_send=test_send_requested) + sent = step_send_new(conn, settings) else: log.info("Outside work hours, skipping new SMS sends") @@ -548,7 +441,6 @@ def main(): send_telegram( f"📦 Платное хранение\nОтправлено: {sent}\nДоставлено: {delivered}", settings.get("telegram_chat_id", TELEGRAM_CHAT_ID), - was_test_mode=bool(settings.get("test_mode", True)), ) # Обновляем баланс @@ -568,8 +460,6 @@ def main(): 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) diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index fdb3f29..5d61ef3 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -29,8 +29,6 @@ import os import sys import json import logging -import time -import fcntl from datetime import datetime, timezone, timedelta import requests @@ -111,46 +109,18 @@ def load_settings(conn): # ─── SMS API ───────────────────────────────────────────────────────────────── def normalize_phone(phone): - """Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой. - - Обрабатляет: - - +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX - - Текст/имена в поле (Екатерина +7978...) → извлекает только цифры - - Два номера в одном поле (XXX / YYY) → берёт первый (обрезает до 11 цифр) - - Минимум 10 цифр (без 7), максимум 11 (с 7) - """ + """Нормализует телефон: только цифры, начинается с 7""" clean = "".join(c for c in str(phone) if c.isdigit()) - if len(clean) < 10: - return None - - # If 8 prefix (Russian landline style) → replace with 7 - if clean.startswith("8") and len(clean) == 11: + if clean.startswith("8"): clean = "7" + clean[1:] - # If starts with 7 and is 11 digits → ok - elif clean.startswith("7") and len(clean) == 11: - pass - # If 10 digits (no country code) → prepend 7 - elif len(clean) == 10: - clean = "7" + clean - # If too long (multiple numbers concatenated) → take first 10 digits + prepend 7 - elif len(clean) > 11: - # Try to extract first number: look for 10-digit sequence starting with 9 - # Common case: "9242377967 / 89783225219" → "9242377967" (10 digits) - clean10 = clean[:10] - clean = "7" + clean10 - # If starts with 7 but wrong length → prepend 7 to first 10 digits 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, @@ -204,7 +174,19 @@ def fetch_balance(api_id): # ─── Telegram ──────────────────────────────────────────────────────────────── def send_telegram(message, chat_id): - pass # Telegram notifications moved to n8n+Supabase integration + if not TELEGRAM_BOT_TOKEN: + log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram") + return + try: + resp = requests.post( + f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + timeout=10, + ) + if resp.status_code != 200: + log.warning(f"Telegram error: {resp.text[:200]}") + except Exception as e: + log.warning(f"Telegram failed: {e}") # ─── State Machine ─────────────────────────────────────────────────────────── @@ -216,8 +198,7 @@ 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.sms_attempts + og.customer_phone_normalized, og.delivery_link, og.notification_status FROM order_groups og WHERE og.delivery_status = 'pending_confirmation' AND og.delivery_link IS NOT NULL @@ -262,19 +243,12 @@ def get_sms_to_check(conn, max_duration_min): return [dict(r) for r in cur.fetchall()] def get_sms_expired(conn, max_duration_min): - """Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены). - Также считаем общее количество failed попыток для группы. - """ + """Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)""" with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, scl.attempts, scl.created_at, - og.customer_name, og.group_key, og.delivery_link, - 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 + og.customer_name, og.group_key, og.delivery_link FROM sms_campaign_log scl JOIN order_groups og ON og.id = scl.order_group_id WHERE scl.campaign_type = 'second_sms' @@ -284,6 +258,7 @@ def get_sms_expired(conn, max_duration_min): 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()) @@ -323,22 +298,6 @@ def update_order_group(conn, group_id, fields): # ─── Проверка рабочего времени ──────────────────────────────────────────────── - -def get_sms_days(conn): - """Read sms_days from business_schedule_settings. Returns comma-separated string.""" - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") - row = cur.fetchone() - if row and row.get("sms_days"): - days = row["sms_days"] - if isinstance(days, list): - return ",".join(str(d) for d in days) - return str(days) - except Exception: - pass - return "1,2,3,4,5" # Default: Mon-Fri - def is_within_work_hours(settings): """Проверка: сейчас рабочие часы. settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') @@ -362,21 +321,12 @@ def is_within_work_hours(settings): # ─── Основная логика ───────────────────────────────────────────────────────── -def step_send_new(conn, settings, test_send=False): +def step_send_new(conn, settings): """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования""" api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) - 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) + groups = get_groups_to_send(conn) log.info(f"Step 1: {len(groups)} groups to send second SMS") sent_count = 0 @@ -399,8 +349,7 @@ def step_send_new(conn, settings, test_send=False): 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") + sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}" # ТЕСТОВЫЙ РЕЖИМ send_phone = phone @@ -412,28 +361,23 @@ def step_send_new(conn, settings, test_send=False): 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)), + attempts=1, ) # ДВОЙНАЯ ЗАЩИТА: 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, @@ -441,12 +385,10 @@ def step_send_new(conn, settings, test_send=False): 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, { @@ -510,13 +452,11 @@ def step_check_status(conn, settings): 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})") + log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}/{max_attempts})") update_sms_log(conn, log_id, status="expired") + # Сбрасываем на first_sms_sent для повторной отправки update_order_group(conn, group_id, { "notification_status": "first_sms_sent", - "sms_attempts": attempts, - "second_sms_sent_at": None, - "next_notification_check_at": None, }) else: update_order_group(conn, group_id, { @@ -559,31 +499,22 @@ def step_handle_expired(conn, settings): 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}") + log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} minutes") - if effective_attempts < max_attempts: - # Повторная отправка 2й SMS (остаёмся в текущем шаге) - # second_sms_sent_at = NULL чтобы get_groups_to_send нашёл группу + if attempts < max_attempts: + # Сбрасываем на first_sms_sent для повторной отправки 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") + log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}), notification_status→first_sms_sent") else: # Все попытки исчерпаны → ручное управление update_order_group(conn, group_id, { "notification_status": "manual_required", - "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" @@ -594,50 +525,12 @@ 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 Second Campaign — START") conn = get_db_conn() try: settings = load_settings(conn) - # Override work_days with business_schedule sms_days - settings["work_days"] = get_sms_days(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')}") @@ -651,7 +544,7 @@ def main(): work_hours = is_within_work_hours(settings) sent = 0 if work_hours: - sent = step_send_new(conn, settings, test_send=test_send_requested) + sent = step_send_new(conn, settings) else: log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends") @@ -684,8 +577,6 @@ def main(): 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) diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index 9e33369..8f0a1b8 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -1,42 +1,44 @@ /** * @file SmsCampaignPanel.jsx * @description SMS Campaign management for mega_admin. - * Campaign cards + settings + log table + queue with next-send times + * Campaign selector cards + settings + log table + "Проверить снова" button */ import React, { useState, useEffect, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; -import { supabase, supabaseUrl, supabaseAnonKey } from "../../supabaseClient"; -import { Pagination } from "../UI/Pagination"; +import { supabase } from "../../supabaseClient"; import { SmsCampaignStats } from "./SmsCampaignStats"; -// ── Campaigns ──────────────────────────────────────────────────────────────── +// ── Campaigns ─────────────────────────────────────────────────────────────── const CAMPAIGNS = [ - { key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки", queueStatus: "link_ready" }, - { key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал", queueStatus: "first_sms_sent" }, - { key: "manual", label: "Ручное управление", icon: "🔧", desc: "Перевод к ручному согласованию после двух SMS", queueStatus: "second_sms_sent" }, - { key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении", queueStatus: null }, + { key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" }, + { key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" }, + { key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" }, + { key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" }, ]; const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"]; -const MANUAL_CAMPAIGN = "manual"; // ── Days of week ───────────────────────────────────────────────────────────── const DAYS = [ - { num: 1, short: "Пн" }, { num: 2, short: "Вт" }, { num: 3, short: "Ср" }, - { num: 4, short: "Чт" }, { num: 5, short: "Пт" }, { num: 6, short: "Сб" }, + { num: 1, short: "Пн" }, + { num: 2, short: "Вт" }, + { num: 3, short: "Ср" }, + { num: 4, short: "Чт" }, + { num: 5, short: "Пт" }, + { num: 6, short: "Сб" }, { num: 7, short: "Вс" }, ]; // ── Status labels ──────────────────────────────────────────────────────────── const STATUS_LABELS = { - sent: "SMS отправлено", - checking: "В пути к абоненту", - delivered: "✓ Доставлено", - send_failed: "Не отправлено", + sent: "Отправлено", + checking: "Проверяется", + delivered: "Доставлено", + send_failed: "Ошибка отправки", error: "Ошибка доставки", - expired: "Срок истёк", + expired: "Истёк срок", limit_exceeded: "Лимит превышен", manual_override: "Ручной режим", }; @@ -52,20 +54,9 @@ const STATUS_TONES = { manual_override: "warning", }; -const STATUS_ICONS = { - sent: "📤", - checking: "📡", - delivered: "✅", - send_failed: "❌", - error: "⛔", - expired: "⏰", - limit_exceeded: "🚫", - manual_override: "✋", -}; - // ── SMS code labels ────────────────────────────────────────────────────────── const SMS_CODE_LABELS = { - "100": "В очереди SMS.ru", "101": "Передано оператору", "102": "В пути", + "100": "В очереди", "101": "Оператору", "102": "В пути", "103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором", "106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено", "130": "Лимит на номер/день", "131": "Лимит одинаковых/мин", @@ -75,41 +66,6 @@ const SMS_CODE_LABELS = { "232": "Лимит одинаковых/день", }; -// ── Notification status labels (for queue) ────────────────────────────────── -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": "✓ Платное хранение: отправлено", - "completed": "✅ Завершено", - "confirmed": "✅ Согласовано клиентом", - "address_required": "⚠ Требуется адрес", - "draft": "Черновик", -}; - -const NOTIF_TONES = { - "not_started": "neutral", - "link_ready": "warning", - "sms_sending": "info", - "first_sms_sent": "info", - "second_sms_sending": "info", - "second_sms_sent": "info", - "send_failed": "danger", - "manual_required": "warning", - "paid_storage_sending": "info", - "paid_storage_sent": "accent", - "completed": "accent", - "confirmed": "accent", - "address_required": "danger", - "draft": "neutral", -}; - // ── Helpers ────────────────────────────────────────────────────────────────── const fmtTime = (ts) => { if (!ts) return "—"; @@ -121,15 +77,6 @@ const fmtTime = (ts) => { } catch { return ts; } }; -const fmtTimeShort = (ts) => { - if (!ts) return "—"; - try { - return new Date(ts).toLocaleString("ru-RU", { - hour: "2-digit", minute: "2-digit", - }); - } catch { return ts; } -}; - const fmtPhone = (phone) => { if (!phone) return "—"; const digits = String(phone).replace(/\D/g, ""); @@ -150,119 +97,6 @@ const fmtElapsed = (ts) => { return `${h}ч ${rest}м назад`; }; -// ── Date filter helpers ─────────────────────────────────────────────── -const isToday = (ts) => { - if (!ts) return false; - const d = new Date(ts); - const now = new Date(); - return d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear(); -}; - -const isYesterday = (ts) => { - if (!ts) return false; - const d = new Date(ts); - const y = new Date(); - y.setDate(y.getDate() - 1); - return d.getDate() === y.getDate() && d.getMonth() === y.getMonth() && d.getFullYear() === y.getFullYear(); -}; - -const isWithin7d = (ts) => { - if (!ts) return false; - return new Date(ts).getTime() >= Date.now() - 7 * 24 * 3600 * 1000; -}; - -const passesDateFilter = (ts, df) => { - if (df === "all" || !df) return true; - if (df === "today") return isToday(ts); - if (df === "yesterday") return isYesterday(ts); - if (df === "7d") return isWithin7d(ts); - return true; -}; - -const passesLinkFilter = (isOpened, lf) => { - if (lf === "all" || !lf) return true; - if (lf === "opened") return isOpened === true; - if (lf === "not_opened") return isOpened !== true; - return true; -}; - -// smsFilter: "no_sms" = group has no sms_sent_at and no first/second_sms_sent_at -const passesSmsFilter = (group, sf) => { - if (sf === "all" || !sf) return true; - if (sf === "no_sms") { - return !group.sms_sent_at && !group.first_sms_sent_at && !group.second_sms_sent_at; - } - return true; -}; - -const fmtCountdown = (ts) => { - if (!ts) return "—"; - const diff = new Date(ts).getTime() - Date.now(); - if (diff <= 0) return "сейчас"; - const m = Math.floor(diff / 60000); - if (m < 60) return `через ${m} мин`; - const h = Math.floor(m / 60); - const rest = m % 60; - return `через ${h}ч ${rest}м`; -}; - -// ── Agreement formatter (delivery/pickup status) ──────────────────────────── -const fmtAgreement = (g) => { - if (!g) return { text: "—", tone: "neutral", icon: "⏳" }; - const ds = g.delivery_status; - - // Terminal states first — delivery already happened or cancelled - if (ds === "picked_up") { - return { text: "✅ Самовывоз завершён", tone: "accent", icon: "✅" }; - } - if (ds === "delivered") { - return { text: "✅ Доставлено", tone: "accent", icon: "✅" }; - } - if (ds === "cancelled") { - return { text: "❌ Отменён", tone: "danger", icon: "❌" }; - } - - // If client already chose date/time — show it - if (g.delivery_type === "pickup" || g.pickup_date) { - const date = g.pickup_date ? new Date(g.pickup_date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }) : ""; - const slot = g.pickup_time_slot || ""; - const hasAll = date && slot; - return { text: `🏪 Самовывоз${date ? " " + date : ""}${slot ? ", " + slot : ""}${!hasAll ? " (неполно)" : ""}`, tone: "accent", icon: "🏪" }; - } - if (g.delivery_date) { - const date = new Date(g.delivery_date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); - const time = g.delivery_time || ""; - // Use delivery_address, or customer_address if it's NOT 'САМОВЫВОЗ' - const rawAddr = g.delivery_address || (g.customer_address && !/^самовывоз/i.test(g.customer_address.trim()) ? g.customer_address : ""); - const addrShort = rawAddr ? ` (${rawAddr.slice(0, 30)}${rawAddr.length > 30 ? "…" : ""})` : ""; - const missingAddr = !rawAddr && ds === "requires_address"; - return { text: `📦 Доставка${date ? " " + date : ""}${time ? ", " + time : ""}${addrShort}${missingAddr ? " ⚠ адрес" : ""}`, tone: missingAddr ? "warning" : "accent", icon: "📦" }; - } - - // No chosen date — status-based messages - if (ds === "requires_address" || ds === "address_required") { - return { text: "⚠ Адрес нужен", tone: "warning", icon: "⚠" }; - } - if (ds === "manual_confirmation_required") { - return { text: "🔧 Ручное управление", tone: "warning", icon: "🔧" }; - } - if (ds === "paid_storage") { - return { text: "📦 Платное хранение", tone: "warning", icon: "📦" }; - } - if (ds === "problem") { - return { text: "⚠ Проблема", tone: "danger", icon: "⚠" }; - } - // pending_confirmation — not agreed yet - return { text: "⏳ Не согласовано", tone: "neutral", icon: "⏳" }; -}; - -const AGREEMENT_TONES = { - accent: "#22c55e", - warning: "var(--color-warning)", - danger: "var(--color-danger)", - neutral: "var(--color-text-muted)", -}; - const parseWorkDays = (str) => { if (!str) return new Set([1, 2, 3, 4, 5]); return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7)); @@ -270,192 +104,15 @@ const parseWorkDays = (str) => { const serializeWorkDays = (set) => [...set].sort().join(","); -// Calculate next send time based on work hours/days -const calcNextSendTime = (group, settings, campaignKey) => { - if (!settings) return null; - const now = new Date(); - const workStart = settings.work_hours_start ?? 8; - const workEnd = settings.work_hours_end ?? 21; - const workDays = parseWorkDays(settings.work_days); - const delayHours = settings.second_sms_delay_hours ?? 3; - - let base; - if (campaignKey === "first_sms") { - // Next send: next work window - base = new Date(now); - } else if (campaignKey === "second_sms") { - // Second SMS: first_sms_sent_at + delayHours - if (!group.first_sms_sent_at) return null; - base = new Date(group.first_sms_sent_at); - base.setHours(base.getHours() + delayHours); - } else if (campaignKey === "manual") { - // Manual: second_sms_sent_at + auto_manual_after_hours - if (!group.second_sms_sent_at) return null; - base = new Date(group.second_sms_sent_at); - base.setHours(base.getHours() + (settings.auto_manual_after_hours ?? 3)); - } else { - base = new Date(now); - } - - // If base is in the past, find next work window from now - if (base < now) base = new Date(now); - - // Adjust to work hours/days - let candidate = new Date(base); - // Round up to next minute - candidate.setSeconds(0, 0); - - // If outside work hours or not a work day, find next valid slot - for (let i = 0; i < 14 * 24; i++) { // max 14 days - const day = candidate.getDay() === 0 ? 7 : candidate.getDay(); - const hour = candidate.getHours(); - if (workDays.has(day) && hour >= workStart && hour < workEnd) { - return candidate.toISOString(); - } - candidate.setHours(candidate.getHours() + 1, 0, 0, 0); - } - return null; -}; - -// ── Toggle Switch ──────────────────────────────────────────────────────────── -const ToggleSwitch = ({ on, onClick, disabled, size = "md" }) => { - const dims = size === "sm" ? { w: "w-9", h: "h-5", knob: "h-4 w-4", on: "left-[18px]", off: "left-0.5" } - : { w: "w-12", h: "h-7", knob: "h-6 w-6", on: "left-[22px]", off: "left-0.5" }; - return ( - - ); -}; - -// ── Mini Toggle (for timer in card) ────────────────────────────────────────── -const MiniToggle = ({ on, onClick, disabled }) => ( - -); - -// ── KPI Tile ───────────────────────────────────────────────────────────────── -const KpiTile = ({ icon, value, label, color }) => ( -
+ Сбросить отгрузку и вернуть статус? +
++ Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен. + Отгрузка будет очищена, логист увидит что доставка требует доработки. +
+