diff --git a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc b/scripts/__pycache__/sms_first_campaign.cpython-311.pyc
deleted file mode 100644
index 7950aa6..0000000
Binary files a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc and /dev/null differ
diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py
new file mode 100755
index 0000000..f452208
--- /dev/null
+++ b/scripts/autocall_scheduler.py
@@ -0,0 +1,934 @@
+#!/usr/bin/env python3
+"""
+SuperSam — Автодозвон: планировщик (Фаза 3)
+Генерирует call-файлы для АТС клиента (Asterisk) и кладёт их в spool через SSH.
+
+Архитектура: systemd timer (каждые N минут) + этот скрипт.
+Каждый запуск:
+ 1. Читает настройки кампании autocall_slot из sms_campaign_settings.
+ 2. Находит группы, которым пора звонить (тайминг от sms_sent_at + пауза).
+ 3. Генерирует call-файл (Context: SS-Autocall-Slot) и кладёт на АТС.
+ 4. В test_mode звонит только на test_phone.
+
+Безопасность: сценарий НЕ активируется сам — только если enabled=true в БД.
+"""
+import os
+import sys
+import time
+import logging
+import subprocess
+from datetime import datetime, timezone, timedelta
+
+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")
+
+# АТС (клиентская, через OpenVPN)
+ATS_HOST = os.environ.get("ATS_HOST", "192.168.122.203")
+ATS_USER = os.environ.get("ATS_USER", "root")
+ATS_PASS = os.environ.get("ATS_PASS", "tigrissoro")
+ATS_SPOOL = "/var/spool/asterisk/outgoing"
+CALLER_ID = "2565551100"
+
+# --- Транк обзвона -----------------------------------------------------------
+# 'gsm' — GSM-модуль клиента (DBL GoIP-4 на LAN АТС, эндпоинт goip5), основной;
+# 'mmedia' — SIP-транк Miranda (прежний путь).
+# Переключается настройкой autocall_trunk в sms_campaign_settings, без правки кода.
+AUTOCALL_TRUNK_DEFAULT = "gsm"
+GSM_ENDPOINT = os.environ.get("AUTOCALL_GSM_ENDPOINT", "goip5")
+# Префикс линии модуля в single-server mode: линия 1 → "1" (SIM стоит только в ней).
+GSM_LINE_PREFIX = os.environ.get("AUTOCALL_GSM_PREFIX", "1")
+# Сколько одновременных звонков держит модуль: одна SIM = один звонок.
+GSM_TRUNK_LINES_DEFAULT = 1
+
+# Edge function для приёма результата
+API_URL = os.environ.get("AUTOCALL_API_URL", "https://supa.supersamsev.ru/functions/v1/autocall-result")
+API_KEY = os.environ.get("AUTOCALL_API_KEY", "a7e1a7e9b543d22a93b29cb1291cbf7b3dd2ec67f1a4dae2")
+
+LOG_FILE = "/var/log/supersam-autocall.log"
+
+# ─── Логирование ─────────────────────────────────────────────────────────────
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout)],
+)
+log = logging.getLogger("autocall")
+
+# ─── БД ──────────────────────────────────────────────────────────────────────
+
+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 = 'autocall_slot'")
+ row = cur.fetchone()
+ if not row:
+ return {
+ "enabled": False, "test_mode": True, "test_phone": "9067029953",
+ "work_hours_start": 9, "work_hours_end": 20, "work_days": "1,2,3,4,5,6",
+ "delay_after_sms_minutes": 90, "retry_interval_minutes": 120,
+ "attempts_per_day": 3, "max_attempts": 5,
+ "no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00",
+ "call_timeout_seconds": 30, "pickup_calls_enabled": False,
+ }
+ return dict(row)
+
+def resolve_scenario(created_at, now=None):
+ """Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра),
+ со следующего дня → B (день фиксирован = завтра, только половина дня)."""
+ if not created_at:
+ return "A"
+ now = now or datetime.now(timezone.utc)
+ msk_tz = timezone(timedelta(hours=3))
+ created = created_at.astimezone(msk_tz)
+ current = now.astimezone(msk_tz)
+ return "A" if created.date() == current.date() else "B"
+
+
+GROUPS_TO_CALL_SQL = """
+ SELECT og.id, og.customer_phone, og.customer_phone_normalized,
+ og.notification_status, og.delivery_status, og.sms_sent_at,
+ og.autocall_opt_out, og.created_at, og.delivery_type, og.town,
+ (SELECT count(*) FROM autocall_log l
+ WHERE l.order_group_id = og.id) as attempt_count,
+ (SELECT count(*) FROM autocall_log l
+ WHERE l.order_group_id = og.id
+ AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count,
+ (SELECT max(l.created_at) FROM autocall_log l
+ WHERE l.order_group_id = og.id) as last_call_at,
+ -- От чего считаем паузу перед звонком:
+ -- * SMS ушло — от времени отправки;
+ -- * SMS не ушло/не доставлено — от последней попытки отправки
+ -- (владелец: «если SMS не отправлено — звоним через 90 минут
+ -- после последней попытки»).
+ COALESCE(
+ CASE WHEN t.try_at IS NOT NULL
+ AND t.try_status NOT IN ('sent', 'delivered')
+ THEN t.try_at END,
+ og.sms_sent_at
+ ) as call_after
+ FROM order_groups og
+ LEFT JOIN LATERAL (
+ SELECT COALESCE(l.updated_at, l.created_at) AS try_at, l.status AS try_status
+ FROM sms_campaign_log l
+ WHERE l.order_group_id = og.id
+ AND l.campaign_type IN ('first_sms', 'second_sms')
+ ORDER BY COALESCE(l.updated_at, l.created_at) DESC
+ LIMIT 1
+ ) t ON true
+ WHERE og.delivery_status = 'pending_confirmation'
+ AND og.autocall_opt_out = false
+ AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out', 'closed_without_confirmation')
+ -- Пауза после SMS (или после неудачной попытки) прошла
+ AND COALESCE(
+ CASE WHEN t.try_at IS NOT NULL
+ AND t.try_status NOT IN ('sent', 'delivered')
+ THEN t.try_at END,
+ og.sms_sent_at
+ ) IS NOT NULL
+ AND COALESCE(
+ CASE WHEN t.try_at IS NOT NULL
+ AND t.try_status NOT IN ('sent', 'delivered')
+ THEN t.try_at END,
+ og.sms_sent_at
+ ) < NOW() - make_interval(mins => %s)
+ -- Не звонили или прошёл интервал повтора
+ AND (
+ NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id)
+ OR (SELECT max(l.created_at) FROM autocall_log l
+ WHERE l.order_group_id = og.id) < NOW() - make_interval(mins => %s)
+ )
+ -- Не превышен лимит попыток
+ AND (SELECT count(*) FROM autocall_log l
+ WHERE l.order_group_id = og.id) < %s
+ -- Гео-исключения: те же, что у SMS (город исключён целиком
+ -- или канал для города выключен в профиле).
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_rules r
+ WHERE r.city = og.town
+ AND r.enabled = false
+ AND r.campaign_type IN ('*', 'autocall_slot')
+ AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
+ AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_profiles p
+ WHERE p.city = og.town AND p.sms_enabled = false
+ )
+ -- Локация выключена для звонков в админке автодозвона
+ AND NOT EXISTS (
+ SELECT 1 FROM autocall_city_rules c
+ WHERE c.city = og.town AND c.calls_enabled = false
+ )
+ -- Самовывоз звоним только если разрешено настройкой
+ AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
+ ORDER BY call_after ASC
+"""
+
+
+def get_groups_to_call(conn, settings):
+ """Группы, которым пора звонить."""
+ delay_min = settings.get("delay_after_sms_minutes", 90)
+ retry_min = settings.get("retry_interval_minutes", 120)
+ max_attempts = settings.get("max_attempts", 5)
+ pickup_enabled = bool(settings.get("pickup_calls_enabled", False))
+
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(GROUPS_TO_CALL_SQL,
+ (delay_min, retry_min, max_attempts, pickup_enabled))
+ return [dict(r) for r in cur.fetchall()]
+
+def is_work_time(settings, now=None):
+ """Проверяет, входит ли сейчас в рабочее окно."""
+ now = now or datetime.now(timezone.utc)
+ # Рабочие часы в МСК (UTC+3)
+ msk = now.astimezone(timezone(timedelta(hours=3)))
+ hour = msk.hour
+ start = settings.get("work_hours_start", 9)
+ end = settings.get("work_hours_end", 20)
+ work_days = [int(d) for d in str(settings.get("work_days", "1,2,3,4,5,6")).split(",") if d.strip().isdigit()]
+ # Python: Monday=0, Sunday=6. БД: 1=Пн ... 7=Вс
+ weekday = msk.weekday() + 1
+ if weekday not in work_days:
+ return False
+ return start <= hour < end
+
+
+def autocall_window_text(settings):
+ """Окно обзвона словами — для лога (владелец читает логи сам)."""
+ start = settings.get("work_hours_start", 9)
+ end = settings.get("work_hours_end", 20)
+ day_names = {1: "пн", 2: "вт", 3: "ср", 4: "чт", 5: "пт", 6: "сб", 7: "вс"}
+ days = [day_names[d] for d in sorted(
+ int(x) for x in str(settings.get("work_days", "")).split(",") if x.strip().isdigit()
+ ) if d in day_names]
+ return f"{int(start):02d}:00-{int(end):02d}:00 МСК ({', '.join(days) or 'дни не заданы'})"
+
+
+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 get_city_exclusion(conn, city):
+ """Гео-исключение — общее для SMS и звонков.
+
+ Источник тот же, что у SMS: правила города (sms_city_rules) и общий
+ выключатель канала в профиле города (sms_city_profiles.sms_enabled).
+ Правило с campaign_type='*' действует на все каналы, 'autocall_slot' —
+ только на звонки.
+
+ Исключения по номеру (например, «не звонить на МТС», autocall_exclusions)
+ живут отдельно и на SMS не влияют — они про телефонию, а не про город.
+ """
+ if not city:
+ return None
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT city, campaign_type, note
+ FROM sms_city_rules
+ WHERE city = %s
+ AND enabled = false
+ AND campaign_type IN ('*', 'autocall_slot')
+ AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
+ AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
+ LIMIT 1
+ """, (city,))
+ rule = cur.fetchone()
+ if rule:
+ return {
+ "source": "city_rule",
+ "reason": (rule.get("note") or "").strip() or f"город {city} исключён из рассылок",
+ }
+ cur.execute("SELECT sms_enabled, note FROM sms_city_profiles WHERE city = %s LIMIT 1", (city,))
+ profile = cur.fetchone()
+ if profile is not None and profile.get("sms_enabled") is False:
+ return {
+ "source": "city_profile",
+ "reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен",
+ }
+ # Только звонки: переключатель локации в админке автодозвона
+ # (на SMS не влияет — уведомления по городу продолжают уходить).
+ cur.execute("SELECT calls_enabled, note FROM autocall_city_rules WHERE city = %s LIMIT 1", (city,))
+ calls_rule = cur.fetchone()
+ if calls_rule is not None and calls_rule.get("calls_enabled") is False:
+ return {
+ "source": "city_calls_off",
+ "reason": (calls_rule.get("note") or "").strip() or f"звонки по {city} выключены в админке",
+ }
+ return None
+
+
+def get_exclusion_match(conn, phone):
+ """Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
+ phone = normalize_phone(phone)
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT rule_type, value, reason
+ FROM autocall_exclusions
+ WHERE active = true
+ AND (
+ (rule_type = 'phone' AND %s = value)
+ OR
+ (rule_type = 'prefix' AND %s LIKE value || '%%')
+ )
+ LIMIT 1
+ """, (phone, phone))
+ return cur.fetchone()
+
+def load_work_days(conn, field="pickup_days"):
+ """Рабочие дни по графику компании (1=пн … 7=вс).
+
+ График правит админ в «График работы» (business_schedule_settings):
+ `pickup_days` — дни склада, `delivery_days` — дни доставки. Звонки обязаны
+ опираться именно на эти данные: в пятницу «послезавтра» — это воскресенье,
+ когда ни самовывоз, ни доставка не работают.
+ """
+ columns = {"pickup_days", "delivery_days"} # белый список: имя колонки идёт в SQL
+ column = field if field in columns else "pickup_days"
+ default = [1, 2, 3, 4, 5]
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(f"SELECT {column} AS days FROM business_schedule_settings WHERE id = 1")
+ row = cur.fetchone() or {}
+ raw = row.get("days") or default
+ days = sorted({int(str(d).strip()) for d in raw if str(d).strip().isdigit()})
+ return days or default
+ except Exception as e:
+ log.warning(f"Не удалось прочитать график работы ({column}), беру пн-пт: {e}")
+ return default
+
+
+def load_work_hours(conn):
+ """Рабочие часы склада и доставки из «Графика работы» (админка).
+
+ Возвращает минуты от полуночи — сравнивать часы строками нельзя.
+ Дефолты повторяют прежнее поведение кода: первая половина до 12:00,
+ «сегодня» предлагалось весь день.
+ """
+ defaults = {
+ "pickup_half_end": 12 * 60,
+ "pickup_same_day_cutoff": 24 * 60,
+ "pickup_close": 18 * 60,
+ "delivery_half_end": 15 * 60,
+ "delivery_close": 21 * 60,
+ }
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT pickup_half_end, pickup_same_day_cutoff, pickup_close,
+ delivery_half_end, delivery_close
+ FROM business_schedule_settings WHERE id = 1
+ """)
+ row = cur.fetchone() or {}
+ hours = {}
+ for key, fallback in defaults.items():
+ value = row.get(key)
+ if value is None:
+ hours[key] = fallback
+ continue
+ hours[key] = value.hour * 60 + value.minute
+ return hours
+ except Exception as exc: # noqa: BLE001 — часы не критичны, звоним по дефолту
+ log.warning("Не удалось прочитать рабочие часы, беру значения по умолчанию: %s", exc)
+ return dict(defaults)
+
+
+def work_day_window(start_date, work_days, count):
+ """`count` ближайших рабочих дней начиная со `start_date` включительно.
+
+ Выходные и нерабочие дни не сокращают окно, а пропускаются.
+ """
+ days = []
+ day = start_date
+ guard = 0
+ while len(days) < count and guard < 30:
+ if day.isoweekday() in work_days:
+ days.append(day)
+ day += timedelta(days=1)
+ guard += 1
+ return days
+
+
+def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
+ """Даты, на которые предлагаем самовывоз.
+
+ Правило владельца: окно считается **рабочими днями** — день готовности
+ плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают
+ окно, а пропускаются):
+ • готов в понедельник → пн, вт, ср
+ • готов в пятницу → пт, пн, вт (сб и вс склад закрыт)
+ • готов в субботу → пн, вт (сам выходной не предлагаем)
+ • готов вчера → остаток окна начиная с сегодня
+ • окно прошло → пусто, звонить не о чем
+
+ cutoff_minutes — время, после которого «сегодня» уже не предлагаем
+ (склад не успеет собрать заказ к выдаче, настройка админки).
+ """
+ now = now or datetime.now(timezone.utc)
+ work_days = work_days or [1, 2, 3, 4, 5]
+ tz = timezone(timedelta(hours=3))
+ local_now = now.astimezone(tz)
+ today = local_now.date()
+ ready = (sms_sent_at or now).astimezone(tz).date()
+
+ window = work_day_window(ready, work_days, 3)
+
+ first_day = today
+ # Поздно для сегодняшнего забора — окно начинается со следующего дня.
+ if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes:
+ first_day = today + timedelta(days=1)
+ return [d.isoformat() for d in window if d >= first_day]
+
+
+def delivery_window(sms_sent_at, work_days=None, now=None):
+ """Даты, на которые предлагаем доставку.
+
+ Правило владельца: доставляем **только рабочими днями** и никогда в день
+ готовности — нужно время собрать маршрут. Значит, окно — два ближайших
+ рабочих дня после готовности:
+ • готов в понедельник → вт, ср
+ • готов в пятницу → пн, вт (в субботу и воскресенье не доставляем)
+ • звонок в среду по заказу, готовому в понедельник → ср, чт
+ """
+ now = now or datetime.now(timezone.utc)
+ work_days = work_days or [1, 2, 3, 4, 5]
+ tz = timezone(timedelta(hours=3))
+ today = now.astimezone(tz).date()
+ ready = (sms_sent_at or now).astimezone(tz).date()
+
+ start = max(today, ready + timedelta(days=1))
+ return [d.isoformat() for d in work_day_window(start, work_days, 2)]
+
+
+def is_first_half_available(now=None, half_end_minutes=None):
+ """До границы половины дня (настройка склада, по умолчанию 12:00)
+ первую половину дня ещё можно предлагать клиенту."""
+ now = now or datetime.now(timezone.utc)
+ local = now.astimezone(timezone(timedelta(hours=3)))
+ limit = 12 * 60 if half_end_minutes is None else half_end_minutes
+ return local.hour * 60 + local.minute < limit
+
+
+def resolve_call_type(group):
+ """Тип звонка: pkp (самовывоз) или dlv (доставка).
+
+ Приоритет — явный call_type (ручной/тестовый запрос из очереди): его
+ выбирает логист. Если не задан — определяем по типу получения заказа.
+ От типа зависит озвучка (ru/ss_*_pkp vs ru/ss_*_dlv).
+ """
+ ct = (group.get("call_type") or "").strip().lower()
+ if ct in ("dlv", "pkp"):
+ return ct
+ return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv"
+
+
+def trunk_name(settings):
+ """Транк обзвона. Настройка autocall_trunk из sms_campaign_settings (админка).
+ Пустое/неизвестное значение трактуем как основной транк — GSM-модуль."""
+ return (settings.get("autocall_trunk") or AUTOCALL_TRUNK_DEFAULT).strip().lower()
+
+
+def trunk_channel(settings, dial):
+ """Канал для call-файла.
+
+ dial — «0» + 10 цифр номера (формат SIP-транка Miranda).
+ GoIP в single-server mode ждёт <префикс линии><номер в формате 8XXXXXXXXXX>:
+ для линии 1 и 10-значного dial это «18» + dial[1:] (тот же формат, что в
+ рабочем диалплане клиента: Goto(GSM-Out,1${EXTEN},1) для _89XXXXXXXXX).
+ """
+ if trunk_name(settings) == "gsm":
+ return f"PJSIP/{GSM_LINE_PREFIX}8{dial[1:]}@{GSM_ENDPOINT}"
+ return f"PJSIP/{dial}@mmedia"
+
+
+def trunk_capacity(settings):
+ """Сколько одновременных звонков держит транк. None — не ограничено (SIP-транк)."""
+ if trunk_name(settings) != "gsm":
+ return None
+ try:
+ return max(1, int(settings.get("autocall_trunk_lines") or GSM_TRUNK_LINES_DEFAULT))
+ except (TypeError, ValueError):
+ return GSM_TRUNK_LINES_DEFAULT
+
+
+def trunk_active_calls(settings):
+ """Сколько каналов транка занято на АТС прямо сейчас (и исходящие, и входящие)."""
+ pattern = GSM_ENDPOINT if trunk_name(settings) == "gsm" else "mmedia"
+ cmd = [
+ "sshpass", "-p", ATS_PASS,
+ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "ConnectTimeout=10", "-q",
+ f"{ATS_USER}@{ATS_HOST}",
+ f'asterisk -rx "core show channels concise" | grep -c "PJSIP/{pattern}-" || true',
+ ]
+ try:
+ proc = subprocess.run(cmd, capture_output=True, timeout=25)
+ lines = [l for l in proc.stdout.decode().splitlines() if l.strip()]
+ return int(lines[-1]) if lines else 0
+ except Exception as e:
+ log.warning(f"Не смог узнать занятость транка {pattern}: {e}")
+ # Не роняем обзвон из-за разовой сетевой ошибки: считаем линию свободной.
+ return 0
+
+
+def trunk_free_slots(settings):
+ """Сколько звонков можно поставить в спул прямо сейчас. None — без ограничения."""
+ cap = trunk_capacity(settings)
+ if cap is None:
+ return None
+ return cap - trunk_active_calls(settings)
+
+
+def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
+ """Формирует содержимое call-файла.
+
+ manual=True — звонок запущен руками из карточки заказа. Логист выбрал
+ конкретного клиента, поэтому звоним ему, даже если кампания в тестовом
+ режиме: тестовый режим страхует массовый обзвон, а не ручной запрос.
+ """
+ phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
+ phone = normalize_phone(phone)
+ # Формат набора: 0 + номер без ведущей 7
+ dial = "0" + phone[1:] if phone.startswith("7") else "0" + phone
+
+ # Тест-звонок из UI: звоним ровно на введённый из UI номер (settings.test_mode не важен),
+ # но помечаем was_test_mode=1, чтобы в логе и в БД было видно, что это тест.
+ test_mode = bool(settings.get("test_mode", True))
+ if is_test_call:
+ test_mode = True
+ elif manual and group.get("id"):
+ test_mode = False
+ elif test_mode:
+ test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
+ dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
+
+ # Сценарий: в день добавления группы → A (завтра/послезавтра),
+ # со следующего дня → B (только завтра + половина дня).
+ scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
+ call_type = resolve_call_type(group)
+
+ # График работы (админка) — источник рабочих дней. Если нужный список ПУСТ
+ # (все дни сняты или настройка не заполнена), окно считать нечем: сами функции
+ # окна подменяют пустой список дефолтом «Пн–Пт», и робот начал бы предлагать
+ # дни, когда компания не работает. Такой звонок лучше пропустить и сказать в логе.
+ if call_type == "pkp" and not settings.get("pickup_work_days"):
+ log.warning(
+ "Skip group %s: в «Графике работы» не заполнены дни самовывоза — окно не посчитать",
+ group.get("id"),
+ )
+ return None
+ if call_type == "dlv" and not settings.get("delivery_work_days"):
+ log.warning(
+ "Skip group %s: в «Графике работы» не заполнены дни доставки — окно не посчитать",
+ group.get("id"),
+ )
+ return None
+
+ # Самовывоз: конкретные даты передаём в диалог явно — робот называет их
+ # голосом и возвращает выбранную дату, а не абстрактный «день 1/2».
+ pickup_days = []
+ if call_type == "pkp":
+ pickup_days = pickup_window(
+ group.get("sms_sent_at") or group.get("created_at"),
+ settings.get("pickup_work_days"),
+ cutoff_minutes=(settings.get("work_hours") or {}).get("pickup_same_day_cutoff"),
+ )
+ if not pickup_days:
+ # Окно самовывоза закрыто (заказ готов более двух дней назад).
+ return None
+
+ # Доставка: только рабочие дни графика и не в день готовности. Суббота —
+ # выходной, поэтому «готов в пятницу» даёт понедельник и вторник.
+ delivery_days = []
+ if call_type == "dlv":
+ delivery_days = delivery_window(
+ group.get("sms_sent_at") or group.get("created_at"),
+ settings.get("delivery_work_days"),
+ )
+ if not delivery_days:
+ log.warning(
+ "Skip group %s: пустой график доставки — окно не посчитать", group.get("id")
+ )
+ return None
+
+ lines = [
+ f"Channel: {trunk_channel(settings, dial)}",
+ f"CallerID: {CALLER_ID}",
+ "Context: SS-Autocall-Slot",
+ "Extension: s",
+ "Priority: 1",
+ "MaxRetries: 0",
+ "WaitTime: 45",
+ "Archive: yes",
+ f"Setvar: SCENARIO={scenario}",
+ f"Setvar: CALLTYPE={call_type}",
+ f"Setvar: GROUP_ID={group.get('id') or ''}",
+ f"Setvar: ATTEMPT={attempt_no}",
+ f"Setvar: CUSTOMER_PHONE={phone}",
+ f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 else ''}",
+ f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 else ''}",
+ f"Setvar: PICKUP_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}",
+ f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}",
+ f"Setvar: DELIVERY_DAY1={delivery_days[0] if len(delivery_days) > 0 else ''}",
+ f"Setvar: DELIVERY_DAY2={delivery_days[1] if len(delivery_days) > 1 else ''}",
+ f"Setvar: API_URL={API_URL}",
+ f"Setvar: API_KEY={API_KEY}",
+ f"Setvar: TEST_MODE={1 if test_mode else 0}",
+ "",
+ ]
+ return "\n".join(lines)
+
+def push_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
+ """Кладёт call-файл на АТС через SSH."""
+ content = build_call_file(group, settings, attempt_no, is_test_call, manual)
+ if content is None:
+ # Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем.
+ log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка")
+ return False
+ gid = str(group.get("id") or "test")[:8]
+ filename = f"ss-{gid}-{int(time.time())}.call"
+ # Пишем файл на АТС через sshpass + cat
+ cmd = [
+ "sshpass", "-p", ATS_PASS,
+ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "ConnectTimeout=10", "-q",
+ f"{ATS_USER}@{ATS_HOST}",
+ f"cat > {ATS_SPOOL}/{filename}",
+ ]
+ try:
+ proc = subprocess.run(cmd, input=content.encode(), capture_output=True, timeout=20)
+ if proc.returncode != 0:
+ log.error(f"Failed to push call file for {group['id']}: {proc.stderr.decode()[:200]}")
+ return False
+ log.info(f"Pushed call file {filename} for group {group['id']} (phone={group.get('customer_phone')})")
+ return True
+ except Exception as e:
+ log.error(f"SSH push error for {group['id']}: {e}")
+ return False
+
+def get_queued_calls(conn):
+ """Ручные запросы «Позвонить сейчас» из autocall_queue."""
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call,
+ q.call_type
+ FROM autocall_queue q
+ WHERE q.processed_at IS NULL
+ ORDER BY q.requested_at ASC
+ """)
+ rows = [dict(r) for r in cur.fetchall()]
+ # Подтянем телефон из order_groups для звонков по конкретному заказу
+ # (для test-call order_group_id может быть NULL — номер берём из q.phone)
+ ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"])
+ if ids:
+ cur.execute("""
+ SELECT og.id, og.customer_phone, og.customer_phone_normalized,
+ og.created_at, og.delivery_type, og.town,
+ og.notification_status, og.delivery_date, og.autocall_opt_out
+ FROM order_groups og WHERE og.id IN %s
+ """, (ids,))
+ og = {r["id"]: r for r in cur.fetchall()}
+ for r in rows:
+ g = og.get(r["order_group_id"]) if r["order_group_id"] else None
+ if g:
+ r["customer_phone_normalized"] = g.get("customer_phone_normalized")
+ r.setdefault("customer_phone", g.get("customer_phone"))
+ r.setdefault("created_at", g.get("created_at"))
+ r.setdefault("delivery_type", g.get("delivery_type"))
+ r.setdefault("town", g.get("town"))
+ r["notification_status"] = g.get("notification_status")
+ r["delivery_date"] = g.get("delivery_date")
+ r["autocall_opt_out"] = g.get("autocall_opt_out")
+ return rows
+
+
+def mark_queue_processed(conn, queue_id):
+ with conn.cursor() as cur:
+ cur.execute("UPDATE autocall_queue SET processed_at = now() WHERE id = %s", (queue_id,))
+ conn.commit()
+
+
+def process_queue(conn, settings):
+ """Забирает ручные запросы и создаёт call-файлы. Возвращает число созданных."""
+ queued = get_queued_calls(conn)
+ if not queued:
+ return 0
+ # Номера попыток для ручных звонков — с учётом уже сделанных автозвонков
+ attempt_map = {}
+ pushed = 0
+ # Ручные звонки идут через тот же транк обзвона: если у модуля занята линия,
+ # заявку из очереди не выкидываем — она дождётся следующего тика.
+ free = trunk_free_slots(settings)
+ for q in queued:
+ group_id = q["order_group_id"]
+ is_test = bool(q.get("is_test_call"))
+ # Номер: для теста — из q.phone; для заказа — подтянут в customer_phone через get_queued_calls
+ phone = q.get("phone") or q.get("customer_phone") or ""
+ if not phone:
+ log.warning(f"Queue item {q['queue_id']} has no phone, skip")
+ continue
+ # Гео-исключение (как для SMS): город исключён целиком
+ city_excl = get_city_exclusion(conn, q.get("town"))
+ if city_excl:
+ log.info(f"Queue item {q['queue_id']} skipped by city rule ({q.get('town')}): {city_excl['reason']}")
+ mark_queue_processed(conn, q["queue_id"])
+ continue
+ # Исключение: на этот номер не звоним (например, МТС-диапазон)
+ excl = get_exclusion_match(conn, phone)
+ if excl:
+ log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
+ mark_queue_processed(conn, q["queue_id"])
+ continue
+ # Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) —
+ # роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено.
+ # Заявку НЕ закрываем (правило владельца) — просто не обрабатываем.
+ # ВАЖНО: 'manual_required' («нужен менеджер») сюда не относится — это как раз
+ # заказ, где клиент дату НЕ выбрал, звонок робота по нему допустим.
+ agreed = (str(q.get("notification_status") or "") == "confirmed"
+ or q.get("delivery_date") is not None
+ or q.get("autocall_opt_out") is True)
+ if agreed and not is_test:
+ log.info(
+ f"Queue item {q['queue_id']} (group {group_id}): заказ уже согласован "
+ f"(статус {q.get('notification_status')}, дата {q.get('delivery_date')}) — "
+ "звонок не нужен, заявка остаётся в очереди"
+ )
+ continue
+ if group_id not in attempt_map:
+ if group_id:
+ with conn.cursor() as cur:
+ cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,))
+ attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1
+ else:
+ attempt_map[group_id] = 1
+ attempt_no = attempt_map[group_id]
+ g = dict(q)
+ g["id"] = group_id
+ g["customer_phone"] = phone
+ # Для теста не подставляем customer_phone_normalized (нет заказа)
+ if is_test:
+ g["customer_phone_normalized"] = None
+ if free is not None and free <= 0:
+ log.info(
+ f"Свободных линий транка {trunk_name(settings)} нет — "
+ "ручные звонки ждут следующего тика"
+ )
+ break
+ if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
+ mark_queue_processed(conn, q["queue_id"])
+ pushed += 1
+ if free is not None:
+ free -= 1
+ else:
+ # Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
+ # по правилу владельца заявка живёт, пока окно не откроется или
+ # пока её не обработают вручную. Интерфейс в этот момент блокирует
+ # кнопку автодозвона и объясняет причину (autocallAvailability.js).
+ log.info(
+ f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — "
+ "заявка остаётся в очереди до открытия окна"
+ )
+ attempt_map[group_id] = attempt_no + 1
+ return pushed
+
+
+def has_pending_items(conn):
+ """Дёшево: есть ли непрочитанные ручные запросы. Для fast-path, чтобы не шуметь в логе."""
+ with conn.cursor() as cur:
+ cur.execute("SELECT 1 FROM autocall_queue WHERE processed_at IS NULL LIMIT 1")
+ return cur.fetchone() is not None
+
+
+def backfill_no_answer(conn, settings):
+ """Дописывает no_answer звонкам, которые не получили ответа сети.
+
+ Call-файл с истёкшим WaitTime не запускает AGI — в autocall_log ничего
+ не пишется, и «не дозвонились» не появляется никогда. Ищем обработанные
+ заявки старше 10 минут без строки в логе и дописываем no_answer.
+ Самозакрывающаяся: вставленная строка сама удовлетворяет проверке
+ существования, повторов не будет.
+ """
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT q.id, q.order_group_id, q.phone, q.is_test_call,
+ q.requested_at, q.processed_at, q.call_type
+ FROM autocall_queue q
+ WHERE q.processed_at IS NOT NULL
+ AND q.processed_at < NOW() - make_interval(mins => 10)
+ ORDER BY q.processed_at ASC
+ LIMIT 20
+ """)
+ rows = [dict(r) for r in cur.fetchall()]
+ backfilled = 0
+ for q in rows:
+ gid = q["order_group_id"]
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ if gid:
+ cur.execute("""
+ SELECT 1 FROM autocall_log l
+ WHERE l.order_group_id = %s AND l.created_at >= %s LIMIT 1
+ """, (gid, q["requested_at"]))
+ else:
+ cur.execute("""
+ SELECT 1 FROM autocall_log l
+ WHERE l.order_group_id IS NULL
+ AND l.customer_phone = %s
+ AND l.created_at >= %s LIMIT 1
+ """, (normalize_phone(q["phone"] or ""), q["requested_at"]))
+ if cur.fetchone():
+ continue # строка есть (AGI отработал) — дописывать нечего
+ # Телефон из заказа (или нормализованный из заявки для pure-теста)
+ phone = normalize_phone(q["phone"] or "")
+ group_created = None
+ group_dtype = None
+ if gid:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT customer_phone_normalized, customer_phone, created_at,
+ delivery_type, town
+ FROM order_groups WHERE id = %s
+ """, (gid,))
+ grow = cur.fetchone() or {}
+ phone = (grow.get("customer_phone_normalized")
+ or normalize_phone(grow.get("customer_phone") or "")
+ or phone)
+ group_created = grow.get("created_at")
+ group_dtype = grow.get("delivery_type")
+ # По исключённым номерам звонка не было — строку не пишем
+ # (статус «исключение» и так показывается из настроек).
+ if get_exclusion_match(conn, phone):
+ continue
+ # Гео-исключение: город, по которому звонки не делаем вовсе
+ if get_city_exclusion(conn, q.get("town")):
+ continue
+ scenario = resolve_scenario(group_created) if gid else "A"
+ with conn.cursor() as cur:
+ if gid:
+ cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (gid,))
+ attempt_no = (cur.fetchone()[0] or 0) + 1
+ else:
+ attempt_no = 1
+ test_mode = bool(q["is_test_call"] or settings.get("test_mode", True))
+ call_type = resolve_call_type({"call_type": q.get("call_type"), "delivery_type": group_dtype})
+ with conn.cursor() as cur:
+ cur.execute("""
+ INSERT INTO autocall_log
+ (order_group_id, customer_phone, attempt_no, scenario,
+ call_started_at, outcome, was_test_mode, channel_state, call_type)
+ VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer', %s)
+ """, (gid, phone, attempt_no, scenario,
+ q["processed_at"] or q["requested_at"], test_mode, call_type))
+ conn.commit()
+ backfilled += 1
+ log.info(f"Backfilled no_answer for queue item {q['id']} (phone={phone})")
+ return backfilled
+
+
+def main():
+ conn = get_db_conn()
+ try:
+ settings = load_settings(conn)
+ # Рабочие дни и часы из «Графика работы» (админка): выходные и нерабочие
+ # дни робот не предлагает ни по самовывозу, ни по доставке.
+ settings["pickup_work_days"] = load_work_days(conn, "pickup_days")
+ settings["delivery_work_days"] = load_work_days(conn, "delivery_days")
+ settings["work_hours"] = load_work_hours(conn)
+
+ # Добор «не взял трубку»: заявки, по которым сеть так и не ответила
+ # (истёкший call-файл, AGI не запускался). Только работа с БД.
+ try:
+ nb = backfill_no_answer(conn, settings)
+ if nb:
+ log.info(f"Backfilled {nb} no_answer rows")
+ except Exception as e:
+ log.warning(f"backfill_no_answer failed: {e}")
+
+ # Fast-path: если кампания выключена и очередь пуста — выходим молча.
+ # Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе.
+ if not settings.get("enabled", False) and not has_pending_items(conn):
+ return
+
+ # 0) Ручные звонки «Позвонить сейчас» — даже если кампания выключена,
+ # но в рабочее время и enabled_or_queue. Ручной запрос — явный сигнал.
+ if is_work_time(settings):
+ nq = process_queue(conn, settings)
+ if nq:
+ log.info(f"Processed {nq} queued manual calls")
+ else:
+ log.info("Outside work hours, manual queue not processed")
+
+ if not settings.get("enabled", False):
+ log.info("Autocall campaign disabled (enabled=false), skipping auto")
+ return
+
+ # Окно обзвона: вне рабочих часов и дней авто-кампания не звонит вообще
+ # (владелец: «звонить вне окна разрешённого нельзя»). Раньше окно проверялось
+ # только для ручной очереди, и автоматика дозванивалась в 21:00 и позже: повтор,
+ # чей интервал истёк вечером, теперь просто ждёт ближайшего рабочего утра.
+ if not is_work_time(settings):
+ log.info(f"Outside work hours ({autocall_window_text(settings)}), auto calls skipped")
+ return
+
+ groups = get_groups_to_call(conn, settings)
+ log.info(f"Found {len(groups)} groups to call")
+
+ # Транк с ограничением по линиям (GSM-модуль: одна SIM = один звонок в момент).
+ # Больше, чем есть свободных линий, в спул не ставим: иначе часть звонков
+ # отвалится «занято», AGI не запустится и заявка уедет в «не ответил».
+ # Не успевшие просто ждут следующего тика таймера.
+ free = trunk_free_slots(settings)
+ if free is not None and free <= 0:
+ log.info(
+ f"Транк {trunk_name(settings)} занят (лимит {trunk_capacity(settings)} звонков) — "
+ "переносим звонки на следующий тик"
+ )
+ return
+
+ pushed = 0
+ for group in groups:
+ phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
+ # Гео-исключение (то же, что для SMS): город исключён целиком
+ city_excl = get_city_exclusion(conn, group.get("town"))
+ if city_excl:
+ log.info(f"Group {group['id']} skipped by city rule ({group.get('town')}): {city_excl['reason']}")
+ continue
+ # Исключение: на этот номер не звоним (например, МТС-диапазон)
+ excl = get_exclusion_match(conn, phone)
+ if excl:
+ log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
+ continue
+ attempt_no = (group.get("attempt_count") or 0) + 1
+ if free is not None and free <= 0:
+ log.info(
+ f"Свободных линий транка {trunk_name(settings)} нет — "
+ "остальные звонки на следующий тик"
+ )
+ break
+ if push_call_file(group, settings, attempt_no):
+ pushed += 1
+ if free is not None:
+ free -= 1
+ time.sleep(1) # пауза между звонками
+
+ log.info(f"Pushed {pushed} call files")
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/backup-code-s3.sh b/scripts/backup-code-s3.sh
old mode 100644
new mode 100755
index 4748cc0..d458d8f
--- a/scripts/backup-code-s3.sh
+++ b/scripts/backup-code-s3.sh
@@ -15,6 +15,7 @@ S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
SOURCE_DIR="/opt/supersam"
+SOURCE_DIR_DEV="/opt/supersam-dev"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
@@ -27,14 +28,15 @@ echo "[$(date)] Starting code backup..."
# ── Archive ──────────────────────────────────────────────────────────────
echo "[$(date)] Archiving ${SOURCE_DIR}..."
+# Рантайм-чекаут + актуальный dev-чекаут (источник прод-фронта)
tar -czf "${ARCHIVE_FILE}" \
- --directory="${SOURCE_DIR}" \
+ --directory="/" \
--exclude="node_modules" \
--exclude=".git" \
--exclude="dist" \
--exclude="volumes" \
--exclude="__pycache__" \
- . 2>/dev/null
+ "${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
diff --git a/scripts/check_autocall_window.py b/scripts/check_autocall_window.py
new file mode 100755
index 0000000..5288b84
--- /dev/null
+++ b/scripts/check_autocall_window.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Детерминированная проверка окна обзвона автодозвона.
+
+Проверяет, что автоматическая кампания НЕ ставит call-файлы вне рабочего окна
+(часы и дни берутся из настроек кампании `autocall_slot`), не звонит в выходные
+и звонит внутри окна. Ничего не звонит: push_call_file, process_queue, backfill
+и выборка кандидатов подменены, время — фейковое (модульная datetime).
+
+Запускать на .8 через обёртку (она подставит DB_*):
+
+ bash /opt/supersam/scripts/sms_run_with_db.sh \\
+ /opt/supersam/scripts/check_autocall_window.py [путь_к_проверяемому_файлу]
+
+Без аргумента проверяется установленный /opt/supersam/scripts/autocall_scheduler.py
+(можно проверять и файл-кандидат до установки). Ожидания считаются ИЗ НАСТРОЕК
+(work_hours_start/end, work_days), поэтому проверка не устаревает при смене графика.
+Зачем: инцидент 28.09.2026 — окно проверялось только для ручной очереди, и робот
+позвонил клиенту в 21:03 МСК.
+"""
+import importlib.util, datetime as _dt, sys
+from importlib.machinery import SourceFileLoader
+
+PATH = sys.argv[1] if len(sys.argv) > 1 else "/opt/supersam/scripts/autocall_scheduler.py"
+_loader = SourceFileLoader("acs", PATH)
+spec = importlib.util.spec_from_loader("acs", _loader)
+m = importlib.util.module_from_spec(spec)
+_loader.exec_module(m)
+
+MSK = _dt.timezone(_dt.timedelta(hours=3))
+
+
+class FakeDT(_dt.datetime):
+ fixed = None
+
+ @classmethod
+ def now(cls, tz=None):
+ if tz is None:
+ return cls.fixed.astimezone(MSK).replace(tzinfo=None)
+ return cls.fixed.astimezone(tz)
+
+
+m.datetime = FakeDT
+
+# ── настройки, из которых считаем ожидания ───────────────────────────────────
+_conn = m.get_db_conn()
+_settings = m.load_settings(_conn)
+_conn.close()
+START = int(_settings.get("work_hours_start", 9))
+END = int(_settings.get("work_hours_end", 20))
+WORK_DAYS = sorted(int(d) for d in str(_settings.get("work_days", "")).split(",") if d.strip().isdigit())
+print(f"настройки кампании: окно {START:02d}:00-{END:02d}:00 МСК, дни {WORK_DAYS}")
+
+FAKE_GROUP = {
+ "id": "00000000-0000-0000-0000-000000000000",
+ "customer_phone": "79000000000",
+ "customer_phone_normalized": "79000000000",
+ "town": "Симферополь",
+ "created_at": _dt.datetime(2026, 9, 28, 6, 0, tzinfo=_dt.timezone.utc),
+ "delivery_type": "pickup",
+ "attempt_count": 0,
+ "no_input_count": 0,
+ "sms_sent_at": _dt.datetime(2026, 9, 28, 5, 0, tzinfo=_dt.timezone.utc),
+ "notification_status": "first_sms_sent",
+ "delivery_status": "pending_confirmation",
+ "autocall_opt_out": False,
+}
+
+pushes = []
+
+
+def _fake_push(group, settings, attempt_no, is_test_call=False, **kw):
+ pushes.append(group["customer_phone"])
+ return True
+
+
+def run_case(label, when_msk, expect_push):
+ FakeDT.fixed = when_msk.astimezone(MSK)
+ pushes.clear()
+ m.push_call_file = _fake_push
+ m.process_queue = lambda conn, settings: 0
+ m.backfill_no_answer = lambda conn, settings: 0
+ m.get_groups_to_call = lambda conn, settings: [FAKE_GROUP]
+ m.trunk_free_slots = lambda settings: 1
+ m.trunk_capacity = lambda settings: 1
+ m.trunk_name = lambda settings: "goip5"
+ m.get_city_exclusion = lambda conn, town: None
+ m.get_exclusion_match = lambda conn, phone: None
+ m.main()
+ ok = (len(pushes) > 0) == expect_push
+ print(f"{'PASS' if ok else 'FAIL'}: {label} → звонков {len(pushes)} (ожидали {'есть' if expect_push else 'нет'})")
+ return ok
+
+
+DAY_NAMES = {1: "пн", 2: "вт", 3: "ср", 4: "чт", 5: "пт", 6: "сб", 7: "вс"}
+MONDAY = _dt.datetime(2026, 9, 28, tzinfo=MSK) # 28.09.2026 — понедельник
+results = []
+
+# Каждый день недели в середине окна: звонок только если день рабочий.
+for num in range(1, 8):
+ day = MONDAY + _dt.timedelta(days=num - 1)
+ when = day.replace(hour=min(START + 1, 23))
+ results.append(run_case(f"{DAY_NAMES[num]} {when:%d.%m %H:%M} МСК (середина окна)", when, num in WORK_DAYS))
+
+# Границы окна — на первом рабочем дне недели.
+work_day = MONDAY + _dt.timedelta(days=WORK_DAYS[0] - 1) if WORK_DAYS else MONDAY
+if WORK_DAYS:
+ results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {START - 1 if START else 0:02d}:00 МСК (час до окна)", work_day.replace(hour=max(START - 1, 0)), False))
+ results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {START:02d}:00 МСК (начало окна)", work_day.replace(hour=START), True))
+ results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {END - 1:02d}:59 МСК (конец окна)", work_day.replace(hour=END - 1), True))
+ results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {END:02d}:00 МСК (час после окна)", work_day.replace(hour=END), False))
+
+# Инцидент 28.09.2026: поздние звонки — звонков быть не должно.
+results.append(run_case("пн 19:58 МСК (инцидент)", _dt.datetime(2026, 9, 28, 19, 58, tzinfo=MSK), False))
+results.append(run_case("пн 21:03 МСК (инцидент)", _dt.datetime(2026, 9, 28, 21, 3, tzinfo=MSK), False))
+
+print("\nИТОГ:", "ВСЕ PASS" if all(results) else "ЕСТЬ FAIL")
+sys.exit(0 if all(results) else 1)
diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py
index 13cd80f..192701b 100644
--- a/scripts/sms_first_campaign.py
+++ b/scripts/sms_first_campaign.py
@@ -34,6 +34,12 @@ import requests
import psycopg2
from psycopg2.extras import RealDictCursor
+try: # лимитер sms.ru (модуль лежит рядом со скриптом)
+ from sms_rate_limit import check_send_allowed, alert_due
+except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
+ check_send_allowed = None
+ alert_due = None
+
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
@@ -64,6 +70,84 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
+# ─── Тексты SMS ──────────────────────────────────────────────────────────────
+# Шаблоны живут в админке (sms_campaign_settings):
+# sms_text_template — текст для ДОСТАВКИ
+# sms_text_template_pickup — текст для САМОВЫВОЗА
+# Подстановки: {link}/{ссылка} — ссылка согласования,
+# {order}/{номер}/{номер_доставки} — номер доставки (010796),
+# {code}/{код}/{код_выдачи} — код выдачи (7598).
+# Пустой шаблон → DEFAULT_TEXT_*.
+
+DEFAULT_TEXT_DELIVERY = "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
+DEFAULT_TEXT_PICKUP = (
+ "Ваш заказ готов. Номер доставки {order}, код выдачи {code}. "
+ "Согласуйте дату самовывоза по ссылке: {link}"
+)
+DEFAULT_TEXT_NOTIFICATION = "Ваш заказ готов."
+
+
+def is_pickup_group(group):
+ return (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
+
+
+def first_order_digits(value):
+ """Номер доставки из order_numbers («ТП\\-010722» → «010722»)."""
+ items = value if isinstance(value, (list, tuple)) else [value]
+ for item in items:
+ digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit())
+ if digits:
+ return digits
+ return ""
+
+
+def apply_sms_placeholders(text, group, link):
+ """Подстановка переменных шаблона в текст SMS."""
+ if not text:
+ return text
+
+ order = first_order_digits(group.get("order_numbers"))
+ code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit())
+ replacements = (
+ ("{link}", link), ("{ссылка}", link),
+ ("{номер_доставки}", order), ("{номер}", order), ("{order}", order),
+ ("{код_выдачи}", code), ("{код}", code), ("{code}", code),
+ )
+
+ result = text
+ for token, value in replacements:
+ result = result.replace(token, value)
+ return result.strip()
+
+
+def build_sms_text(group, settings):
+ """Текст SMS по типу отгрузки: самовывоз → pickup-шаблон, доставка → обычный.
+
+ Приоритет:
+ 1. текст профиля города (режим «Уведомление», sms_city_profiles) — как было;
+ 2. шаблон кампании из админки для этого типа отгрузки;
+ 3. дефолт по типу отгрузки.
+ """
+ link = group.get("delivery_link") or ""
+ mode = (group.get("sms_mode") or "link").strip().lower()
+ profile_text = (group.get("sms_text_custom") or "").strip()
+ pickup = is_pickup_group(group)
+ template = (settings.get(
+ "sms_text_template_pickup" if pickup else "sms_text_template"
+ ) or "").strip()
+ if not template:
+ template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
+
+ if mode == "notification":
+ if profile_text:
+ return apply_sms_placeholders(profile_text, group, link)
+ # уведомление без ссылки: шаблон годится, только если в нём нет {link}
+ if template and "{link}" not in template:
+ return apply_sms_placeholders(template, group, link)
+ return apply_sms_placeholders(DEFAULT_TEXT_NOTIFICATION, group, link)
+
+ return apply_sms_placeholders(template, group, link)
+
# ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig(
@@ -172,7 +256,7 @@ def send_telegram(message, chat_id):
# ─── State Machine ───────────────────────────────────────────────────────────
-def get_groups_to_send(conn):
+def get_groups_to_send(conn, max_attempts):
"""Группы, которым ещё не отправили SMS.
Режимы (из sms_city_profiles):
@@ -183,7 +267,8 @@ def get_groups_to_send(conn):
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.delivery_type, og.town,
+ og.delivery_type, og.town, og.sms_attempts,
+ og.order_numbers, og.pickup_code,
-- Профиль города (fallback на дефолтные значения если нет записи)
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
COALESCE(scp.delivery_text, '') as delivery_text,
@@ -210,6 +295,16 @@ def get_groups_to_send(conn):
AND COALESCE(og.notification_status, '') = 'link_ready'
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
AND COALESCE(scp.sms_enabled, true) = true
+ -- Гео-исключения — общие для SMS и звонков: правило города
+ -- (campaign_type '*' — все каналы) блокирует и отправку SMS.
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_rules r
+ WHERE r.city = og.town
+ AND r.enabled = false
+ AND r.campaign_type IN ('*', 'first_sms')
+ AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
+ AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
+ )
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
@@ -218,8 +313,15 @@ def get_groups_to_send(conn):
AND scl.status IN ('sent', 'checking')
AND scl.created_at > NOW() - INTERVAL '2 hours'
)
+ -- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку,
+ -- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны)
+ AND (
+ SELECT count(*) FROM sms_campaign_log scl2
+ WHERE scl2.order_group_id = og.id
+ AND scl2.campaign_type = 'first_sms'
+ ) < %s
ORDER BY og.created_at ASC
- """)
+ """, (max_attempts,))
return [dict(r) for r in cur.fetchall()]
def get_sms_to_check(conn, max_duration_min):
@@ -300,6 +402,22 @@ 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 count_sms_attempts(conn, group_id, campaign_type="first_sms"):
+ """Сколько раз эту SMS уже пытались отправить (строк в логе этой кампании).
+
+ Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
+ новую строку, поэтому в самой строке всегда было бы attempts=1, условие
+ «attempts < max_attempts» выполнялось бы вечно и кампания слала бы SMS
+ каждые 5 минут бесконечно (проверено на заказе ТП-010543: 457 отправок за
+ 3,5 суток, ни одна не доставлена, в ручную работу заказ не ушёл).
+ """
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT COUNT(*) FROM sms_campaign_log
+ WHERE order_group_id = %s AND campaign_type = %s
+ """, (group_id, campaign_type))
+ return cur.fetchone()[0]
+
# ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings):
@@ -309,10 +427,13 @@ def step_send_new(conn, settings):
max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3)
- groups = get_groups_to_send(conn)
+ groups = get_groups_to_send(conn, max_attempts)
log.info(f"Step 1: {len(groups)} groups to send SMS")
sent_count = 0
+ limited_count = 0
+ limited_last = None
+ limited_info = None
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "—")
@@ -336,15 +457,12 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue
- # Формируем текст SMS по режиму
+ # Формируем текст SMS по типу отгрузки (доставка / самовывоз) и режиму
+ sms_text = build_sms_text(group, settings)
if sms_mode == "notification":
- # Уведомление без ссылки на согласование
- sms_text = custom_text if custom_text else "Ваш заказ готов."
- log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link")
+ log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}): {sms_text!r}")
else:
- # Стандартный флоу — ссылка на согласование
- sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
- log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})")
+ log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type}): {sms_text!r}")
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone
@@ -352,7 +470,19 @@ def step_send_new(conn, settings):
send_phone = settings.get("test_phone", "79788382260")
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
+ # --- Лимит sms.ru: не больше N SMS на один номер за окно (иначе блокировка) ---
+ if check_send_allowed is not None:
+ _ok, _reason, _info = check_send_allowed(conn, send_phone, settings)
+ if not _ok:
+ limited_count += 1
+ limited_last = _reason
+ limited_info = _info
+ log.warning(f"Group {group_id}: SMS HELD by sms.ru rate limit - {_reason}")
+ continue # сообщение не потеряно: уйдёт в следующий запуск
+
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
+ attempt_no = count_sms_attempts(conn, group_id) + 1
+ log.info(f"Group {group_id}: attempt {attempt_no}/{max_attempts}")
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id:
@@ -360,11 +490,13 @@ def step_send_new(conn, settings):
campaign_type="first_sms",
order_group_id=group_id,
customer_phone=phone,
+ sent_to=send_phone,
+ was_test_mode=bool(settings.get("test_mode", True)),
sms_id=sms_id,
sms_text=sms_text,
status="sent",
sms_code=code,
- attempts=1,
+ attempts=attempt_no,
)
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
if sms_mode == "notification":
@@ -374,6 +506,7 @@ def step_send_new(conn, settings):
"sms_sent_at": "NOW()",
"first_sms_sent_at": "NOW()",
"status": "first_sms_sent",
+ "sms_attempts": attempt_no,
})
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
else:
@@ -381,6 +514,7 @@ def step_send_new(conn, settings):
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
+ "sms_attempts": attempt_no,
})
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
sent_count += 1
@@ -394,7 +528,7 @@ def step_send_new(conn, settings):
sms_text=sms_text,
status="send_failed",
sms_code=code,
- attempts=1,
+ attempts=attempt_no,
error_message=error,
)
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
@@ -402,9 +536,26 @@ def step_send_new(conn, settings):
update_order_group(conn, group_id, {
"notification_status": "send_failed",
"last_sms_error": error[:200],
+ "sms_attempts": attempt_no,
})
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
+ if limited_count:
+ _win = (limited_info or {}).get("window")
+ _cap = (limited_info or {}).get("per_number")
+ _acct = (limited_info or {}).get("account")
+ msg = (
+ f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
+ f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
+ f"причина: {limited_last}; "
+ f"сообщения не потеряны — уйдут в следующий запуск."
+ )
+ log.warning("Rate limit held %s SMS: %s", limited_count, limited_last)
+ if alert_due is not None and alert_due(conn, settings):
+ send_telegram(msg, tg_chat)
+ else:
+ log.info("Rate limit Telegram alert suppressed (throttled)")
+
return sent_count
def step_check_status(conn, settings):
@@ -425,7 +576,7 @@ def step_check_status(conn, settings):
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)
+ attempts = item.get("attempts") or 1
was_needs_check = item.get("needs_check", False)
code, raw, api_code = check_sms_status(sms_id, api_id)
@@ -471,9 +622,11 @@ def step_check_status(conn, settings):
})
else:
update_order_group(conn, group_id, {
- "notification_status": "send_failed",
- "last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
+ "notification_status": "manual_required",
+ "last_sms_error": f"SMS не доставлена после {max_attempts} попыток (код {code})",
})
+ log.error(f"Group {group_id}: attempts exhausted ({attempts}/{max_attempts}), "
+ f"notification_status→manual_required")
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
elif code in LIMIT_ERROR_CODES:
@@ -509,7 +662,7 @@ def step_handle_expired(conn, settings):
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)
+ attempts = item.get("attempts") or 1
delivery_link = item.get("delivery_link", "")
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
@@ -534,6 +687,63 @@ def step_handle_expired(conn, settings):
tg_chat,
)
+def step_release_exhausted(conn, settings):
+ """Шаг 4: группы, у которых попытки исчерпаны, а статус остался 'link_ready'.
+
+ Такое состояние возникает как следствие сбоя счётчика: группа выпадает из
+ выборки по лимиту попыток, но из очереди не уходит — висит в 'link_ready'
+ вечно, клиент не получает ссылку, в ручную работу заказ не переводится.
+ Шаг закрывает эту дыру: попытки кончились → «нужен менеджер».
+ """
+ max_attempts = settings.get("max_attempts", 2)
+ tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
+
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("""
+ SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
+ (SELECT count(*) FROM sms_campaign_log l
+ WHERE l.order_group_id = og.id
+ AND l.campaign_type = 'first_sms') AS attempts
+ FROM order_groups og
+ WHERE og.delivery_status = 'pending_confirmation'
+ AND COALESCE(og.notification_status, '') = 'link_ready'
+ AND (
+ SELECT count(*) FROM sms_campaign_log l
+ WHERE l.order_group_id = og.id
+ AND l.campaign_type = 'first_sms'
+ ) >= %s
+ -- Активная отправка в работе — не мешаем ей
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_campaign_log l2
+ WHERE l2.order_group_id = og.id
+ AND l2.campaign_type = 'first_sms'
+ AND l2.status IN ('sent', 'checking')
+ )
+ """, (max_attempts,))
+ groups = [dict(r) for r in cur.fetchall()]
+
+ for group in groups:
+ group_id = str(group["id"])
+ name = group.get("customer_name") or group.get("group_key", "—")
+ phone = group.get("customer_phone", "")
+ attempts = group.get("attempts") or max_attempts
+
+ log.warning(f"Group {group_id}: попыток {attempts}/{max_attempts}, статус остался link_ready "
+ f"→ manual_required (клиент SMS не получил)")
+ update_order_group(conn, group_id, {
+ "notification_status": "manual_required",
+ "last_sms_error": f"SMS не доставлена после {attempts} попыток — нужен менеджер",
+ })
+ send_telegram(
+ f"🔧 Нужен менеджер\n{name} ({phone})\n"
+ f"SMS не доставлена после {attempts} попыток — клиенту не ушла ссылка",
+ tg_chat,
+ )
+
+ if groups:
+ log.info(f"Step 4: {len(groups)} groups released from link_ready → manual_required")
+ return len(groups)
+
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
@@ -555,8 +765,9 @@ def main():
sent = step_send_new(conn, settings)
delivered = step_check_status(conn, settings)
step_handle_expired(conn, settings)
+ released = step_release_exhausted(conn, settings)
- log.info(f"Run summary: sent={sent}, delivered={delivered}")
+ log.info(f"Run summary: sent={sent}, delivered={delivered}, released_to_manual={released}")
if sent > 0 or delivered > 0:
send_telegram(
diff --git a/scripts/sms_first_campaign_ref.py b/scripts/sms_first_campaign_ref.py
index 473f6da..d2051a5 100644
--- a/scripts/sms_first_campaign_ref.py
+++ b/scripts/sms_first_campaign_ref.py
@@ -84,6 +84,22 @@ def get_db_conn():
user=DB_USER, password=DB_PASS,
)
+def apply_schedule_work_days(conn, settings):
+ """Дни отправки — из «Графика работы» в ЛК (единый источник правды).
+
+ Если график прочитать не удалось, остаются прежние дни кампании.
+ """
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
+ row = cur.fetchone() or {}
+ days = row.get("sms_days") or []
+ if days:
+ settings["work_days"] = ",".join(str(int(d)) for d in days)
+ except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
+ print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
+ return settings
+
def load_settings(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
@@ -99,7 +115,8 @@ def load_settings(conn):
"test_mode": True,
"test_phone": "79788382260",
}
- return dict(row)
+ settings = dict(row)
+ return apply_schedule_work_days(conn, settings)
# ─── SMS API ─────────────────────────────────────────────────────────────────
diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py
index 99df6e0..909b254 100644
--- a/scripts/sms_manual_campaign.py
+++ b/scripts/sms_manual_campaign.py
@@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine)
Заменяет n8n workflow "Ручное управление"
Логика:
- Если после второй SMS (notification_status = 'second_sms_sent')
- прошло auto_manual_after_hours и клиент не согласовал
+ Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч)
+ после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и
(delivery_status = 'pending_confirmation'):
→ notification_status = 'manual_required'
→ delivery_status = 'manual_confirmation_required'
- → next_notification_check_at = +3 мин
→ Telegram-уведомление
+ Ожидание считаем только по тем каналам, что реально включены в системе
+ (load_channels):
+ • второе приглашение включено → ждём, пока оно уйдёт;
+ • автодозвон включён и номеру звонить можно → ждём звонок, отсчёт
+ начнётся от него (позвонили позже — ожидание начинается заново);
+ • канал выключен / звонить нельзя (не звонить, гео, исключение) —
+ не ждём его и переводим по сроку ожидания.
+
Расписание: 9-21, Пн-Сб (настраивается из админки)
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
"""
@@ -61,13 +68,30 @@ def get_db_conn():
user=DB_USER, password=DB_PASS,
)
+def apply_schedule_work_days(conn, settings):
+ """Дни отправки — из «Графика работы» в ЛК (единый источник правды).
+
+ Если график прочитать не удалось, остаются прежние дни кампании.
+ """
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
+ row = cur.fetchone() or {}
+ days = row.get("sms_days") or []
+ if days:
+ settings["work_days"] = ",".join(str(int(d)) for d in days)
+ except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
+ print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
+ return settings
+
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)
+ settings = dict(row)
+ return apply_schedule_work_days(conn, settings)
# ─── Telegram ────────────────────────────────────────────────────────────────
@@ -94,53 +118,129 @@ def is_within_work_hours(settings):
# ─── State Machine ───────────────────────────────────────────────────────────
-def get_groups_to_manual(conn):
- """Группы, где срок ожидания истёк и нужно перевести в ручное управление.
+def get_groups_to_manual(conn, channels=None, manual_after_hours=3):
+ """Группы, где клиент не выбрал дату/время и пора в ручную работу.
- Сценарии:
- 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 без доставки → зависшая группа
+ Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого
+ приглашения: если клиенту позвонили позже, ожидание начинается заново.
+
+ Каналы берём из настроек: ждём только те шаги, которые реально включены.
+ • второе приглашение включено → ждём, пока оно уйдёт;
+ • автодозвон включён и этому номеру звонить можно → ждём звонка;
+ • канал выключен (или звонить нельзя: не звонить/гео/исключение) —
+ не ждём его и переводим в ручную работу по сроку ожидания.
+
+ Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие
+ заказы в системе ведёт только менеджер, автоматика их не трогает.
"""
+ channels = channels or {}
with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute("""
+ cur.execute(r"""
+ WITH contact AS (
+ SELECT og.id,
+ GREATEST(
+ COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'),
+ COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'),
+ COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'),
+ COALESCE(og.called_at, TIMESTAMP 'epoch'),
+ COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l
+ WHERE l.order_group_id = og.id
+ AND COALESCE(l.status, '') IN
+ ('sent','checking','delivered','send_failed','error','limit_exceeded')),
+ TIMESTAMP 'epoch'),
+ COALESCE((SELECT max(a.created_at) FROM autocall_log a
+ WHERE a.order_group_id = og.id), TIMESTAMP 'epoch')
+ ) AS last_contact_at,
+ (SELECT count(*) FROM autocall_log a
+ WHERE a.order_group_id = og.id) AS call_attempts
+ FROM order_groups og
+ ),
+ callable AS (
+ SELECT og.id,
+ (og.autocall_opt_out = false
+ AND COALESCE(og.customer_phone, '') <> ''
+ AND NOT EXISTS (
+ SELECT 1 FROM autocall_city_rules cr
+ WHERE cr.city = og.town AND cr.calls_enabled = false)
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_rules cr2
+ WHERE cr2.city = og.town AND cr2.enabled = false
+ AND cr2.campaign_type IN ('*', 'autocall_slot')
+ AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE)
+ AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE))
+ AND NOT EXISTS (
+ SELECT 1 FROM autocall_exclusions e
+ WHERE e.active = true
+ AND ((e.rule_type = 'phone'
+ AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
+ OR (e.rule_type = 'prefix'
+ AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
+ LIKE e.value || '%%')))
+ ) AS can_call
+ FROM order_groups og
+ )
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
+ og.sms_attempts,
+ c.last_contact_at, c.call_attempts, cl.can_call
FROM order_groups og
+ JOIN contact c ON c.id = og.id
+ JOIN callable cl ON cl.id = og.id
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 в логе
- -- FIX-4: grace period 30 мин — manual не крадёт свежие link_ready до first_campaign
- OR (COALESCE(og.notification_status, '') = 'link_ready'
- AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW() - INTERVAL '30 minutes')
- 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')
+ -- Клиент уже выбрал дату/время — в ручную не переводим
+ AND og.delivery_date IS NULL
+ -- Контакта не было — отсчитывать не от чего
+ AND c.last_contact_at > TIMESTAMP 'epoch'
+ -- Срок ожидания истёк от последнего контакта
+ AND NOW() - c.last_contact_at >= make_interval(hours => %s)
+ -- Второе приглашение ждём, только если канал включён
+ AND (NOT %s OR og.second_sms_sent_at IS NOT NULL)
+ -- Звонок ждём, только если автодозвон включён и звонить можно
+ AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call)
+ -- Гео-исключения — общие для SMS и ручного управления
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_rules r
+ WHERE r.city = og.town
+ AND r.enabled = false
+ AND r.campaign_type IN ('*', 'manual')
+ AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
+ AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
- ORDER BY og.created_at ASC
- """)
+ ORDER BY c.last_contact_at ASC
+ """, (
+ int(manual_after_hours or 3),
+ bool(channels.get("second_sms")),
+ bool(channels.get("autocall")),
+ ))
return [dict(r) for r in cur.fetchall()]
+
+def load_channels(conn):
+ """Какие каналы связи сейчас включены в системе.
+
+ `first_sms` — первое приглашение, `second_sms` — повторное SMS,
+ `autocall` — робот-автодозвон (настройки кампании 'autocall_slot').
+ От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги.
+ """
+ channels = {"first_sms": True, "second_sms": False, "autocall": False}
+ try:
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT campaign_type, enabled
+ FROM sms_campaign_settings
+ WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot')
+ """)
+ for campaign_type, enabled in cur.fetchall():
+ if campaign_type == "autocall_slot":
+ channels["autocall"] = bool(enabled)
+ else:
+ channels[campaign_type] = bool(enabled)
+ except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS
+ pass
+ return channels
+
def update_order_group(conn, group_id, fields):
with conn.cursor() as cur:
set_parts = []
@@ -157,35 +257,55 @@ def update_order_group(conn, group_id, fields):
# ─── Основная логика ─────────────────────────────────────────────────────────
-def step_move_to_manual(conn, settings):
+def step_move_to_manual(conn, settings, channels=None):
"""Перевод групп в ручное управление"""
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
- delay_minutes = settings.get("auto_manual_after_hours", 3)
+ wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3)
+ channels = channels or load_channels(conn)
- groups = get_groups_to_manual(conn)
- log.info(f"Step 1: {len(groups)} groups to move to manual")
+ groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours)
+ log.info(
+ f"Step 1: {len(groups)} groups to move to manual "
+ f"(ожидание {wait_hours} ч от последнего контакта; каналы: "
+ f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, "
+ f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, "
+ f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})"
+ )
moved = 0
+ dry_run = os.environ.get("DRY_RUN") == "1"
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", "")
+ attempts = group.get("sms_attempts") or 1
- log.info(f"Group {group_id}: moving to manual_required ({name})")
+ if dry_run:
+ log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})")
+ moved += 1
+ continue
+
+ log.info(
+ f"Group {group_id}: moving to manual_required ({name}); "
+ f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}"
+ )
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"delivery_status": "manual_confirmation_required",
- "sms_attempts": 2,
+ # Попытки НЕ подменяем: показываем столько, сколько реально отправлено
+ "sms_attempts": attempts,
"last_sms_error": None,
- "next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
+ # Дальше ждать нечего — проверки по этому заказу закончились
+ "next_notification_check_at": None,
"status": "manual_required",
})
send_telegram(
f"🔧 Ручное управление\n{name} ({phone})\n"
- f"Клиент не согласовал доставку после двух SMS\n"
+ f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта "
+ f"({'звонки' if channels.get('autocall') else 'SMS'})\n"
f"Ссылка: {delivery_link}",
tg_chat,
)
@@ -247,8 +367,9 @@ def main():
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)
+
+ channels = load_channels(conn)
+ moved = step_move_to_manual(conn, settings, channels=channels)
log.info(f"Run summary: moved_to_manual={moved}")
diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py
index d15e15b..1b8f7c1 100644
--- a/scripts/sms_paid_storage_campaign.py
+++ b/scripts/sms_paid_storage_campaign.py
@@ -27,6 +27,12 @@ import requests
import psycopg2
from psycopg2.extras import RealDictCursor
+try: # лимитер sms.ru (модуль лежит рядом со скриптом)
+ from sms_rate_limit import check_send_allowed, alert_due
+except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
+ check_send_allowed = None
+ alert_due = None
+
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
@@ -51,6 +57,65 @@ DELIVERED_CODE = "103"
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
+# ─── Тексты SMS ──────────────────────────────────────────────────────────────
+# Шаблоны живут в админке (sms_campaign_settings):
+# sms_text_template — текст для ДОСТАВКИ
+# sms_text_template_pickup — текст для САМОВЫВОЗА
+# Подстановки: {link}/{ссылка} — ссылка согласования,
+# {order}/{номер}/{номер_доставки} — номер доставки (010796),
+# {code}/{код}/{код_выдачи} — код выдачи (7598).
+# Пустой шаблон → DEFAULT_TEXT_*.
+
+DEFAULT_TEXT_DELIVERY = (
+ "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. "
+ "Заберите заказ или согласуйте доставку: {link}"
+)
+DEFAULT_TEXT_PICKUP = (
+ "Заказ переведён на платное хранение. Номер доставки {order}, код выдачи {code}. "
+ "Заберите со склада или согласуйте дату самовывоза: {link}"
+)
+
+
+def first_order_digits(value):
+ """Номер доставки из order_numbers («ТП\\-010722» → «010722»)."""
+ items = value if isinstance(value, (list, tuple)) else [value]
+ for item in items:
+ digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit())
+ if digits:
+ return digits
+ return ""
+
+
+def apply_sms_placeholders(text, group, link):
+ """Подстановка переменных шаблона в текст SMS."""
+ if not text:
+ return text
+
+ order = first_order_digits(group.get("order_numbers"))
+ code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit())
+ replacements = (
+ ("{link}", link), ("{ссылка}", link),
+ ("{номер_доставки}", order), ("{номер}", order), ("{order}", order),
+ ("{код_выдачи}", code), ("{код}", code), ("{code}", code),
+ )
+
+ result = text
+ for token, value in replacements:
+ result = result.replace(token, value)
+ return result.strip()
+
+
+def build_sms_text(group, settings):
+ """Текст SMS о платном хранении по типу отгрузки."""
+ link = group.get("delivery_link") or ""
+ pickup = (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
+ template = (settings.get(
+ "sms_text_template_pickup" if pickup else "sms_text_template"
+ ) or "").strip()
+ if not template:
+ template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
+ return apply_sms_placeholders(template, group, link)
+
# ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig(
@@ -71,6 +136,22 @@ def get_db_conn():
user=DB_USER, password=DB_PASS,
)
+def apply_schedule_work_days(conn, settings):
+ """Дни отправки — из «Графика работы» в ЛК (единый источник правды).
+
+ Если график прочитать не удалось, остаются прежние дни кампании.
+ """
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
+ row = cur.fetchone() or {}
+ days = row.get("sms_days") or []
+ if days:
+ settings["work_days"] = ",".join(str(int(d)) for d in days)
+ except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
+ print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
+ return settings
+
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,))
@@ -82,7 +163,8 @@ def load_settings(conn):
"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)
+ settings = dict(row)
+ return apply_schedule_work_days(conn, settings)
# ─── SMS API ─────────────────────────────────────────────────────────────────
@@ -178,11 +260,21 @@ def get_groups_to_send(conn):
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
+ og.paid_storage_at, og.delivery_type, og.town,
+ og.order_numbers, og.pickup_code
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'paid_storage'
AND og.paid_storage_at IS NOT NULL
+ -- Гео-исключения — общие для SMS и звонков
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_rules r
+ WHERE r.city = og.town
+ AND r.enabled = false
+ AND r.campaign_type IN ('*', 'paid_storage')
+ AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
+ AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
+ )
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
AND og.delivery_link IS NOT NULL
AND og.delivery_link != ''
@@ -278,6 +370,9 @@ def step_send_new(conn, settings):
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
sent_count = 0
+ limited_count = 0
+ limited_last = None
+ limited_info = None
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "—")
@@ -295,17 +390,24 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
continue
- sms_text = (
- f"Ваш заказ переведён на платное хранение. "
- f"Стоимость: 300 ₽/день. "
- f"Заберите заказ или согласуйте доставку: {delivery_link}"
- )
+ sms_text = build_sms_text(group, settings)
+ log.info(f"Group {group_id}: paid_storage SMS ({group.get('delivery_type') or 'delivery'}): {sms_text!r}")
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}")
+ # --- Лимит sms.ru: не больше N SMS на один номер за окно (иначе блокировка) ---
+ if check_send_allowed is not None:
+ _ok, _reason, _info = check_send_allowed(conn, send_phone, settings)
+ if not _ok:
+ limited_count += 1
+ limited_last = _reason
+ limited_info = _info
+ log.warning(f"Group {group_id}: SMS HELD by sms.ru rate limit - {_reason}")
+ continue # сообщение не потеряно: уйдёт в следующий запуск
+
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)
@@ -314,6 +416,8 @@ def step_send_new(conn, settings):
campaign_type=CAMPAIGN_TYPE,
order_group_id=group_id,
customer_phone=phone,
+ sent_to=send_phone,
+ was_test_mode=bool(settings.get("test_mode", True)),
sms_id=sms_id,
sms_text=sms_text,
status="sent",
@@ -342,6 +446,22 @@ def step_send_new(conn, settings):
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
+ if limited_count:
+ _win = (limited_info or {}).get("window")
+ _cap = (limited_info or {}).get("per_number")
+ _acct = (limited_info or {}).get("account")
+ msg = (
+ f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
+ f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
+ f"причина: {limited_last}; "
+ f"сообщения не потеряны — уйдут в следующий запуск."
+ )
+ log.warning("Rate limit held %s SMS: %s", limited_count, limited_last)
+ if alert_due is not None and alert_due(conn, settings):
+ send_telegram(msg, tg_chat)
+ else:
+ log.info("Rate limit Telegram alert suppressed (throttled)")
+
return sent_count
def step_check_status(conn, settings):
diff --git a/scripts/sms_rate_limit.py b/scripts/sms_rate_limit.py
new file mode 100644
index 0000000..36d0451
--- /dev/null
+++ b/scripts/sms_rate_limit.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru).
+
+sms.ru блокирует номер/аккаунт, если на ОДИН номер уходит слишком много SMS
+(«бомбинг»): по умолчанию **не более 8 сообщений на номер за 10 минут**.
+Сейчас кампании узнают об этом только постфактум — по коду ошибки 231/232,
+после чего группа помечается send_failed, а часть сообщений уже потеряна.
+Этот модуль проверяет лимит ДО отправки и придерживает сообщение: оно не
+теряется, а уходит в следующий запуск кампании.
+
+Правило и окно настраиваются в sms_campaign_settings (админка/БД):
+ sms_limit_per_number_10min — сколько SMS на один номер за окно
+ (по умолчанию 8; 0 = проверка выключена)
+ sms_limit_account_10min — сколько SMS всего за окно
+ (по умолчанию 0 = выключено)
+ sms_rate_window_seconds — окно в секундах (по умолчанию 600)
+
+Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru
+(sms_id IS NOT NULL) в пределах окна. Получатель берётся как
+COALESCE(NULLIF(sent_to,''), customer_phone) — то, кому SMS ушла на самом деле;
+в тестовом режиме все сообщения идут на test_phone и считаются за него, как их
+видит sms.ru. Номера сравниваются по последним 10 цифрам (8/7/+7 и т.п.).
+
+Поведение при сбое БД — fail-open: недоступный счётчик не должен останавливать
+доставку, в лог пишется ERROR. Повторные Telegram-алерты подавляются
+(alert_due: не чаще одного раза в час, отметка в sms_campaign_settings).
+"""
+
+import logging
+
+log = logging.getLogger("sms_rate_limit")
+
+DEFAULT_PER_NUMBER = 8 # правило sms.ru: 8 сообщений на номер
+DEFAULT_WINDOW_SECONDS = 600 # ... за 10 минут
+DEFAULT_ACCOUNT = 0 # 0 = аккаунтный лимит не проверяем
+ALERT_THROTTLE_SECONDS = 3600 # не чаще одного алерта в час
+
+COUNT_SQL = """
+ SELECT COUNT(*) FROM sms_campaign_log
+ WHERE created_at > NOW() - (%(win)s || ' seconds')::interval
+ AND sms_id IS NOT NULL
+ {phone_filter}
+"""
+
+PHONE_FILTER = ("AND right(regexp_replace("
+ "COALESCE(NULLIF(sent_to, ''), customer_phone), '\\D', '', 'g'), 10) "
+ "= right(regexp_replace(%(phone)s, '\\D', '', 'g'), 10)")
+
+
+def _int(value, default):
+ """Аккуратно приводит настройку к int (None, '' и мусор → default)."""
+ try:
+ if value is None or value == "":
+ return int(default)
+ return int(value)
+ except (TypeError, ValueError):
+ return int(default)
+
+
+def rules(settings):
+ """Правила лимита из настроек кампании: (на номер, всего, окно)."""
+ s = settings or {}
+ return (
+ _int(s.get("sms_limit_per_number_10min"), DEFAULT_PER_NUMBER),
+ _int(s.get("sms_limit_account_10min"), DEFAULT_ACCOUNT),
+ _int(s.get("sms_rate_window_seconds"), DEFAULT_WINDOW_SECONDS),
+ )
+
+
+def _count(conn, window_seconds, phone=None):
+ sql = COUNT_SQL.format(phone_filter=PHONE_FILTER if phone else "")
+ params = {"win": window_seconds}
+ if phone:
+ params["phone"] = phone
+ with conn.cursor() as cur:
+ cur.execute(sql, params)
+ return cur.fetchone()[0]
+
+
+def check_send_allowed(conn, phone, settings):
+ """Можно ли отправить ещё одну SMS на этот номер.
+
+ Возвращает (ok, reason, info):
+ ok — True (можно) / False (придержать)
+ reason — короткое объяснение для лога и Telegram
+ info — {'per_number': n, 'account': n, 'window': w} для алерта
+ """
+ per_number, account, window = rules(settings)
+ info = {"per_number": per_number, "account": account, "window": window}
+
+ if per_number <= 0 and account <= 0:
+ return True, "лимит выключен в настройках", info
+ if not phone:
+ return True, "пустой номер", info
+
+ try:
+ if per_number > 0:
+ n = _count(conn, window, phone)
+ info["number_count"] = n
+ if n >= per_number:
+ return False, (f"номер {phone}: уже {n} SMS за {window}с "
+ f"(лимит {per_number})"), info
+ if account > 0:
+ total = _count(conn, window)
+ info["account_count"] = total
+ if total >= account:
+ return False, (f"аккаунт: уже {total} SMS за {window}с "
+ f"(лимит {account})"), info
+ except Exception as exc: # noqa: BLE001 — fail-open, доставку не стопим
+ log.error("sms rate limit check failed (fail-open): %s", exc)
+ info["db_error"] = str(exc)
+ return True, f"проверка лимита не удалась ({exc}) — отправляем", info
+
+ return True, "лимит не выбран", info
+
+
+def alert_due(conn, settings, throttle_seconds=ALERT_THROTTLE_SECONDS):
+ """Можно ли отправить Telegram-алерт про придержанные SMS (не чаще раза в час).
+
+ Отметка пишется в sms_campaign_settings.last_limit_alert_at, таблица
+ кампаний — единственная «своя» запись под рукой. Ошибки БД → False,
+ чтобы не заваливать чат алертами.
+ """
+ campaign_type = (settings or {}).get("campaign_type")
+ if not campaign_type:
+ return True # настроек нет — не рискуем потерять уведомление
+ try:
+ with conn.cursor() as cur:
+ cur.execute("""
+ UPDATE sms_campaign_settings
+ SET last_limit_alert_at = NOW()
+ WHERE campaign_type = %s
+ AND (last_limit_alert_at IS NULL
+ OR last_limit_alert_at < NOW() - (%s || ' seconds')::interval)
+ RETURNING id
+ """, (campaign_type, throttle_seconds))
+ ok = cur.fetchone() is not None
+ conn.commit()
+ return ok
+ except Exception as exc: # noqa: BLE001
+ conn.rollback()
+ log.error("alert_due failed: %s", exc)
+ return False
diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py
index da4bde4..e41163c 100644
--- a/scripts/sms_second_campaign.py
+++ b/scripts/sms_second_campaign.py
@@ -35,6 +35,12 @@ import requests
import psycopg2
from psycopg2.extras import RealDictCursor
+try: # лимитер sms.ru (модуль лежит рядом со скриптом)
+ from sms_rate_limit import check_send_allowed, alert_due
+except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
+ check_send_allowed = None
+ alert_due = None
+
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
@@ -63,6 +69,62 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
+# ─── Тексты SMS ──────────────────────────────────────────────────────────────
+# Шаблоны живут в админке (sms_campaign_settings):
+# sms_text_template — текст для ДОСТАВКИ
+# sms_text_template_pickup — текст для САМОВЫВОЗА
+# Подстановки: {link}/{ссылка} — ссылка согласования,
+# {order}/{номер}/{номер_доставки} — номер доставки (010796),
+# {code}/{код}/{код_выдачи} — код выдачи (7598).
+# Пустой шаблон → DEFAULT_TEXT_*.
+
+DEFAULT_TEXT_DELIVERY = "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}"
+DEFAULT_TEXT_PICKUP = (
+ "Напоминаем: номер доставки {order}, код выдачи {code}. "
+ "Выберите дату самовывоза по ссылке: {link}"
+)
+
+
+def first_order_digits(value):
+ """Номер доставки из order_numbers («ТП\\-010722» → «010722»)."""
+ items = value if isinstance(value, (list, tuple)) else [value]
+ for item in items:
+ digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit())
+ if digits:
+ return digits
+ return ""
+
+
+def apply_sms_placeholders(text, group, link):
+ """Подстановка переменных шаблона в текст SMS."""
+ if not text:
+ return text
+
+ order = first_order_digits(group.get("order_numbers"))
+ code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit())
+ replacements = (
+ ("{link}", link), ("{ссылка}", link),
+ ("{номер_доставки}", order), ("{номер}", order), ("{order}", order),
+ ("{код_выдачи}", code), ("{код}", code), ("{code}", code),
+ )
+
+ result = text
+ for token, value in replacements:
+ result = result.replace(token, value)
+ return result.strip()
+
+
+def build_sms_text(group, settings):
+ """Текст второй SMS по типу отгрузки: самовывоз → pickup-шаблон, иначе обычный."""
+ link = group.get("delivery_link") or ""
+ pickup = (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
+ template = (settings.get(
+ "sms_text_template_pickup" if pickup else "sms_text_template"
+ ) or "").strip()
+ if not template:
+ template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
+ return apply_sms_placeholders(template, group, link)
+
# ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig(
@@ -83,6 +145,22 @@ def get_db_conn():
user=DB_USER, password=DB_PASS,
)
+def apply_schedule_work_days(conn, settings):
+ """Дни отправки — из «Графика работы» в ЛК (единый источник правды).
+
+ Если график прочитать не удалось, остаются прежние дни кампании.
+ """
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
+ row = cur.fetchone() or {}
+ days = row.get("sms_days") or []
+ if days:
+ settings["work_days"] = ",".join(str(int(d)) for d in days)
+ except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
+ print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
+ return settings
+
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,))
@@ -104,7 +182,8 @@ def load_settings(conn):
"second_sms_delay_hours": 3,
"auto_manual_after_hours": 3,
}
- return dict(row)
+ settings = dict(row)
+ return apply_schedule_work_days(conn, settings)
# ─── SMS API ─────────────────────────────────────────────────────────────────
@@ -190,7 +269,7 @@ def send_telegram(message, chat_id):
# ─── State Machine ───────────────────────────────────────────────────────────
-def get_groups_to_send(conn):
+def get_groups_to_send(conn, max_attempts):
"""Группы, где первая SMS доставлена, но клиент не согласовал,
и пришло время для второй SMS (next_notification_check_at <= NOW()).
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
@@ -198,7 +277,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.delivery_type, og.town, og.order_numbers, og.pickup_code
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'pending_confirmation'
@@ -209,6 +289,15 @@ def get_groups_to_send(conn):
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
AND COALESCE(scp.second_sms_enabled, true) = true
+ -- Гео-исключения — общие для SMS и звонков
+ AND NOT EXISTS (
+ SELECT 1 FROM sms_city_rules r
+ WHERE r.city = og.town
+ AND r.enabled = false
+ AND r.campaign_type IN ('*', 'second_sms')
+ AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
+ AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
+ )
-- Нет активной второй SMS в логе
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
@@ -217,8 +306,15 @@ def get_groups_to_send(conn):
AND scl.status IN ('sent', 'checking')
AND scl.created_at > NOW() - INTERVAL '2 hours'
)
+ -- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку,
+ -- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны)
+ AND (
+ SELECT count(*) FROM sms_campaign_log scl2
+ WHERE scl2.order_group_id = og.id
+ AND scl2.campaign_type = 'second_sms'
+ ) < %s
ORDER BY og.created_at ASC
- """)
+ """, (max_attempts,))
return [dict(r) for r in cur.fetchall()]
def get_sms_to_check(conn, max_duration_min):
@@ -301,6 +397,21 @@ def update_order_group(conn, group_id, fields):
# ─── Проверка рабочего времени ────────────────────────────────────────────────
+def count_sms_attempts(conn, group_id, campaign_type=CAMPAIGN_TYPE):
+ """Сколько раз эту SMS уже пытались отправить (строк в логе кампании).
+
+ Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
+ новую строку, поэтому в строке всегда было бы attempts=1, условие
+ «attempts < max_attempts» выполнялось бы вечно и вторая SMS уходила бы
+ каждые 5 минут бесконечно.
+ """
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT COUNT(*) FROM sms_campaign_log
+ WHERE order_group_id = %s AND campaign_type = %s
+ """, (group_id, campaign_type))
+ return cur.fetchone()[0]
+
def is_within_work_hours(settings):
"""Проверка: сейчас рабочие часы.
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
@@ -328,11 +439,15 @@ 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)
- groups = get_groups_to_send(conn)
+ groups = get_groups_to_send(conn, max_attempts)
log.info(f"Step 1: {len(groups)} groups to send second SMS")
sent_count = 0
+ limited_count = 0
+ limited_last = None
+ limited_info = None
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "—")
@@ -352,7 +467,8 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
continue
- sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
+ sms_text = build_sms_text(group, settings)
+ log.info(f"Group {group_id}: second SMS ({group.get('delivery_type') or 'delivery'}), town={group.get('town') or '—'}: {sms_text!r}")
# ТЕСТОВЫЙ РЕЖИМ
send_phone = phone
@@ -360,7 +476,19 @@ def step_send_new(conn, settings):
send_phone = settings.get("test_phone", "79788382260")
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
+ # --- Лимит sms.ru: не больше N SMS на один номер за окно (иначе блокировка) ---
+ if check_send_allowed is not None:
+ _ok, _reason, _info = check_send_allowed(conn, send_phone, settings)
+ if not _ok:
+ limited_count += 1
+ limited_last = _reason
+ limited_info = _info
+ log.warning(f"Group {group_id}: SMS HELD by sms.ru rate limit - {_reason}")
+ continue # сообщение не потеряно: уйдёт в следующий запуск
+
log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
+ attempt_no = count_sms_attempts(conn, group_id) + 1
+ log.info(f"Group {group_id}: second SMS attempt {attempt_no}/{max_attempts}")
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id:
@@ -368,11 +496,13 @@ def step_send_new(conn, settings):
campaign_type=CAMPAIGN_TYPE,
order_group_id=group_id,
customer_phone=phone,
+ sent_to=send_phone,
+ was_test_mode=bool(settings.get("test_mode", True)),
sms_id=sms_id,
sms_text=sms_text,
status="sent",
sms_code=code,
- attempts=1,
+ attempts=attempt_no,
)
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
update_order_group(conn, group_id, {
@@ -390,7 +520,7 @@ def step_send_new(conn, settings):
sms_text=sms_text,
status="send_failed",
sms_code=code,
- attempts=1,
+ attempts=attempt_no,
error_message=error,
)
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
@@ -399,6 +529,22 @@ def step_send_new(conn, settings):
})
send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
+ if limited_count:
+ _win = (limited_info or {}).get("window")
+ _cap = (limited_info or {}).get("per_number")
+ _acct = (limited_info or {}).get("account")
+ msg = (
+ f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
+ f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
+ f"причина: {limited_last}; "
+ f"сообщения не потеряны — уйдут в следующий запуск."
+ )
+ log.warning("Rate limit held %s SMS: %s", limited_count, limited_last)
+ if alert_due is not None and alert_due(conn, settings):
+ send_telegram(msg, tg_chat)
+ else:
+ log.info("Rate limit Telegram alert suppressed (throttled)")
+
return sent_count
def step_check_status(conn, settings):
@@ -419,7 +565,7 @@ def step_check_status(conn, settings):
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)
+ attempts = item.get("attempts") or 1
code, raw, api_code = check_sms_status(sms_id, api_id)
@@ -501,7 +647,7 @@ def step_handle_expired(conn, settings):
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)
+ attempts = item.get("attempts") or 1
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
update_sms_log(conn, log_id, status="expired",
diff --git a/scripts/sms_timer_manager.sh b/scripts/sms_timer_manager.sh
index 5302c58..bfe613b 100755
--- a/scripts/sms_timer_manager.sh
+++ b/scripts/sms_timer_manager.sh
@@ -6,11 +6,19 @@
set -euo pipefail
-DB_HOST="10.0.4.12"
+# Адрес контейнера supabase-db меняется при пересоздании, поэтому берём его динамически
+# (как в sms_run_with_db.sh). До 29.09.2026 здесь был зашит 10.0.4.12, к которому нет маршрута:
+# скрипт каждую минуту писал «Failed to query DB, skipping» с 01.07.2026, из-за чего переключатели
+# таймеров и кнопка «Запустить сейчас» в админке молча ничего не делали.
+DB_HOST="$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)"
DB_PORT="5432"
DB_NAME="postgres"
DB_USER="supabase_admin"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
+if [ -z "$DB_HOST" ]; then
+ echo "$(date): ERROR: Cannot resolve supabase-db IP"
+ exit 1
+fi
declare -A TIMERS=(
["first_sms"]="sms-first-campaign.timer"
diff --git a/scripts/sms_timer_manager.sh.bak-dbhost-20260929-080246 b/scripts/sms_timer_manager.sh.bak-dbhost-20260929-080246
new file mode 100755
index 0000000..5302c58
--- /dev/null
+++ b/scripts/sms_timer_manager.sh.bak-dbhost-20260929-080246
@@ -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