Compare commits

..

4 Commits

Author SHA1 Message Date
Mikhail b713bdcf52 бэкап-снимок: прод-правки (перед пушем SMS-лимитера) 2026-10-09 21:11:39 +00:00
ops 5170c4fc93 sms-second: не ждать вторую СМС при выключенной кампании — снятие очереди в second_sms_skipped 2026-10-04 14:30:35 +00:00
hermes d9327f320c SMS: сериализация отправки между кампаниями под аккаунтный лимит sms.ru (8 SMS за 10 минут суммарно)
Ограничение sms.ru — не более 8 SMS за 10 минут ПО АККУНТУ суммарно, на любые
номера (антибомбинг; превышение → отказ с кодами 231/232). Таймеры first/second/
paid стартуют в одну и ту же секунду, поэтому счётчик нужно читать
последовательно, иначе каждый процесс видит «свободно 8» и вместе они
превышают лимит.

- sms_rate_limit.py: acquire_send_lock/release_send_lock на pg_advisory_lock,
  lock_timeout 120с; докстринг, описание настроек и текст алерта приведены к
  правилу «8 суммарно по аккаунту» (страховка «N на номер» остаётся).
- first/second/paid: блокировка берётся сразу после получения списка групп и
  снимается перед выходом; если блокировку взять не удалось — прогон
  пропускается (лучше пропуск, чем блокировка аккаунта), следующая попытка по
  таймеру.
- Настройки: sms_limit_account_10min=8 (основное правило), окно 600с;
  sms_limit_per_number_10min=8 остаётся второй страховкой.

Проверено на клоне боевой базы (sms_rl_test), реальный sms.ru не вызывался:
- 2 процесса × 20 попыток одновременно → ровно 8 отправок (4+4), далее
  «аккаунт: уже 8 SMS за 600с (лимит 8)»; в БД 8 строк с sms_id в окне;
- блокировка: держатель 6с → попытка с таймаутом 2с = False (2.0с), с
  таймаутом 20с = True (2.7с);
- E2E сц.3 (боевой профиль per_number=0/account=8/win=600): 8 ушло, 3
  придержано; сц.1 (per-number=8) — регресс пройден.
2026-10-04 13:20:07 +00:00
root f55b43e716 SMS: проактивный лимитер sms.ru (8 SMS на номер за 10 минут) перед отправкой 2026-10-04 12:12:16 +00:00
25 changed files with 3683 additions and 146 deletions

View File

@ -1,12 +1,32 @@
#!/bin/bash
# SuperSam — прод-деплой фронта.
# Реальная схема: /opt/deployer (порт 9900) на push в main пересобирает образ
# из compose-контекста (/opt/supersam-dev, ветка dev) и пересоздаёт контейнер.
# Поэтому docker cp в контейнер ЖИВЁТ ДО ПЕРВОГО ПЕРЕСОЗДАНИЯ — правки должны
# попадать в git (ветка dev) и собираться в образ.
set -e
cd /opt/supersam
git pull origin main
docker compose -f docker-compose.app.yml up -d --build
echo 'Deploy completed at' $(date)
SRC=/opt/supersam-dev
echo "── 1. подтянуть dev ──"
cd "$SRC"
git fetch gitea-public dev
git merge --ff-only gitea-public/dev
git log --oneline -1
# ── Backup code to S3 after deploy ─────────────────────────────────────────
if [ -x ./scripts/backup-code-s3.sh ]; then
echo "Running code backup to S3..."
./scripts/backup-code-s3.sh || echo "WARNING: code backup failed, deploy OK"
fi
echo "── 2. пересобрать образ и пересоздать контейнер ──"
cd /opt/supersam
docker compose -f docker-compose.app.yml up -d --build
echo "── 3. проверка ──"
IDX=$(docker exec supersam-app sh -c 'grep -o "assets/index-[A-Za-z0-9_.-]*\.js" /usr/share/caddy/index.html | head -1')
DP=$(docker exec supersam-app sh -c "grep -o 'DashboardPage-[A-Za-z0-9_-]*\.js' /usr/share/caddy/$IDX | sort -u | head -1")
echo "index=$IDX dashboard=$DP"
echo -n "маркер «Автодозвон»: "
docker exec supersam-app sh -c "grep -c Автодозвон /usr/share/caddy/assets/$DP" || true
echo -n "маркер «шаблоны самовывоза»: "
docker exec supersam-app sh -c "grep -rl sms_text_template_pickup /usr/share/caddy/assets/*.js | wc -l"
echo -n "SW: "
docker exec supersam-app grep -o 'construction-delivery-static-v[0-9]*' /usr/share/caddy/service-worker.js
echo "── 4. бэкап кода в S3 ──"
/opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1 && echo "backup ok" || echo "backup WARN"
echo "Deploy completed at $(date)"

View File

@ -1,7 +1,7 @@
services:
supersam-app:
build:
context: /opt/supersam
context: /opt/supersam-dev
dockerfile: Dockerfile
args:
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}

View File

@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v116";
const RUNTIME_CACHE = "construction-delivery-runtime-v116";
const STATIC_CACHE = "construction-delivery-static-v117";
const RUNTIME_CACHE = "construction-delivery-runtime-v117";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
// Only these are treated as cacheable static assets. Everything else

934
scripts/autocall_scheduler.py Executable file
View File

@ -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()

66
scripts/backup-code-s3.sh Normal file → Executable file
View File

@ -5,6 +5,7 @@
#
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
# Also triggered after deploy via deploy.sh
# Каждая операция (успех или провал) логируется в public.backup_logs.
set -euo pipefail
@ -15,11 +16,44 @@ S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
SOURCE_DIR="/opt/supersam"
SOURCE_DIR_DEV="/opt/supersam-dev"
RETENTION_DAYS=30
DB_CONTAINER="supabase-db"
DB_USER="supabase_admin"
DB_NAME="postgres"
DB_PASS="${POSTGRES_PASSWORD:-$(grep -m1 '^POSTGRES_PASSWORD=' /opt/supabase/docker/.env 2>/dev/null | cut -d= -f2- || true)}"
DB_PASS="${DB_PASS:-4fe80bb21c7c3d17a8d8b226adf7a479}"
BACKUP_TYPE="code"
# ── Logging в public.backup_logs ──────────────────────────────────────────
LOGGED=0
log_backup() {
local status="$1" size="$2" bytes="$3" s3_path="$4" tables="$5"
printf "INSERT INTO public.backup_logs (backup_type, status, file_size, file_size_bytes, s3_path, tables_included) VALUES ('%s', '%s', '%s', %s, '%s', '%s');\n" \
"${BACKUP_TYPE}" "${status}" "${size}" "${bytes:-0}" "${s3_path}" "${tables}" \
| docker exec -i -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
psql -U "${DB_USER}" -d "${DB_NAME}" -q -t -A >/dev/null \
|| echo "[$(date)] WARN: не удалось записать backup_logs"
LOGGED=1
}
on_exit() {
local rc=$?
if [ "${rc}" -ne 0 ] && [ "${LOGGED}" -eq 0 ]; then
echo "[$(date)] FAILED: скрипт завершился с кодом ${rc}"
log_backup "failed" "" 0 "" "exit code ${rc}"
fi
exit "${rc}"
}
trap on_exit EXIT
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
S3_KEY="${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
S3_PATH="s3://${S3_BUCKET}/${S3_KEY}"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
@ -27,23 +61,40 @@ 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="volumes/db" \
--exclude="volumes/storage" \
--exclude="volumes/logs" \
--exclude="volumes/api" \
--exclude="volumes/snippets" \
--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})"
ARCHIVE_BYTES=$(stat -c%s "${ARCHIVE_FILE}")
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE}, ${ARCHIVE_BYTES} bytes)"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_KEY}" --quiet 2>&1
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
echo "[$(date)] Uploaded to ${S3_PATH}"
# ── Verify: объект существует в S3 ────────────────────────────────────────
S3_BYTES=$(mc stat --json "beget/${S3_BUCKET}/${S3_KEY}" 2>/dev/null | grep -o '"size":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "${S3_BYTES}" ]; then
echo "[$(date)] FAILED: объект не найден в S3 после загрузки: ${S3_PATH}"
log_backup "failed" "${ARCHIVE_SIZE}" "${ARCHIVE_BYTES}" "${S3_PATH}" "upload not verified"
exit 1
fi
if [ "${S3_BYTES}" != "${ARCHIVE_BYTES}" ]; then
echo "[$(date)] WARN: размер в S3 (${S3_BYTES}) не совпадает с локальным (${ARCHIVE_BYTES})"
fi
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${ARCHIVE_FILE}"
@ -52,7 +103,7 @@ echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
{ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null || true; } \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
@ -62,4 +113,5 @@ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
fi
done
log_backup "success" "${ARCHIVE_SIZE}" "${ARCHIVE_BYTES}" "${S3_PATH}" "source code"
echo "[$(date)] Code backup complete."

View File

@ -0,0 +1,67 @@
#!/bin/bash
# Supersam app code backup to S3 (Beget Cloud Storage)
# Archives /opt/supersam source code (excluding node_modules, .git, dist)
# and uploads to S3. Retention: keeps last 30 days.
#
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
# Also triggered after deploy via deploy.sh
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
SOURCE_DIR="/opt/supersam"
SOURCE_DIR_DEV="/opt/supersam-dev"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
echo "[$(date)] Starting code backup..."
# ── Archive ──────────────────────────────────────────────────────────────
echo "[$(date)] Archiving ${SOURCE_DIR}..."
# Рантайм-чекаут + актуальный dev-чекаут (источник прод-фронта)
tar -czf "${ARCHIVE_FILE}" \
--directory="/" \
--exclude="node_modules" \
--exclude=".git" \
--exclude="dist" \
--exclude="volumes" \
--exclude="__pycache__" \
"${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${ARCHIVE_FILE}"
echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
if [ -n "$file_date" ] && [[ "$file_date" < "$CUTOFF" ]]; then
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/code/${fname}" --quiet 2>/dev/null
echo "[$(date)] Deleted old backup: ${fname}"
fi
done
echo "[$(date)] Code backup complete."

View File

@ -3,7 +3,8 @@
# Dumps the complete supabase postgres database and uploads to S3.
# Retention: keeps last 30 days.
#
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
# Cron: 0 6,18 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
# Каждая операция (успех или провал) логируется в public.backup_logs.
set -euo pipefail
@ -17,14 +18,40 @@ S3_PREFIX="backups"
DB_CONTAINER="supabase-db"
DB_USER="supabase_admin"
DB_NAME="postgres"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
# Пароль берём из /opt/supabase/docker/.env, фолбэк — известное значение
DB_PASS="${POSTGRES_PASSWORD:-$(grep -m1 '^POSTGRES_PASSWORD=' /opt/supabase/docker/.env 2>/dev/null | cut -d= -f2- || true)}"
DB_PASS="${DB_PASS:-4fe80bb21c7c3d17a8d8b226adf7a479}"
BACKUP_TYPE="pgdump"
RETENTION_DAYS=30
# ── Logging в public.backup_logs ──────────────────────────────────────────
LOGGED=0
log_backup() {
local status="$1" size="$2" bytes="$3" s3_path="$4" tables="$5"
printf "INSERT INTO public.backup_logs (backup_type, status, file_size, file_size_bytes, s3_path, tables_included) VALUES ('%s', '%s', '%s', %s, '%s', '%s');\n" \
"${BACKUP_TYPE}" "${status}" "${size}" "${bytes:-0}" "${s3_path}" "${tables}" \
| docker exec -i -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
psql -U "${DB_USER}" -d "${DB_NAME}" -q -t -A >/dev/null \
|| echo "[$(date)] WARN: не удалось записать backup_logs"
LOGGED=1
}
on_exit() {
local rc=$?
if [ "${rc}" -ne 0 ] && [ "${LOGGED}" -eq 0 ]; then
echo "[$(date)] FAILED: скрипт завершился с кодом ${rc}"
log_backup "failed" "" 0 "" "exit code ${rc}"
fi
exit "${rc}"
}
trap on_exit EXIT
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
S3_KEY="${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
S3_PATH="s3://${S3_BUCKET}/${S3_KEY}"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
@ -37,14 +64,26 @@ docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
| gzip -9 > "${DUMP_FILE}"
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
DUMP_BYTES=$(stat -c%s "${DUMP_FILE}")
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE}, ${DUMP_BYTES} bytes)"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_KEY}" --quiet 2>&1
echo "[$(date)] Uploaded to ${S3_PATH}"
# ── Verify: объект существует в S3 и размер совпадает ─────────────────────
S3_BYTES=$(mc stat --json "beget/${S3_BUCKET}/${S3_KEY}" 2>/dev/null | grep -o '"size":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "${S3_BYTES}" ]; then
echo "[$(date)] FAILED: объект не найден в S3 после загрузки: ${S3_PATH}"
log_backup "failed" "${DUMP_SIZE}" "${DUMP_BYTES}" "${S3_PATH}" "upload not verified"
exit 1
fi
if [ "${S3_BYTES}" != "${DUMP_BYTES}" ]; then
echo "[$(date)] WARN: размер в S3 (${S3_BYTES}) не совпадает с локальным (${DUMP_BYTES})"
fi
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${DUMP_FILE}"
echo "[$(date)] Local cleanup done"
@ -52,7 +91,7 @@ echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
{ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null || true; } \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
@ -62,4 +101,5 @@ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
fi
done
log_backup "success" "${DUMP_SIZE}" "${DUMP_BYTES}" "${S3_PATH}" "all (full pg_dump)"
echo "[$(date)] Backup complete."

View File

@ -0,0 +1,65 @@
#!/bin/bash
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
# Dumps the complete supabase postgres database and uploads to S3.
# Retention: keeps last 30 days.
#
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
DB_CONTAINER="supabase-db"
DB_USER="supabase_admin"
DB_NAME="postgres"
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
echo "[$(date)] Starting pg_dump backup..."
# ── Dump ──────────────────────────────────────────────────────────────────
echo "[$(date)] Dumping database..."
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
| gzip -9 > "${DUMP_FILE}"
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
echo "[$(date)] Uploaded to ${S3_PATH}"
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${DUMP_FILE}"
echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
echo "[$(date)] Deleted old backup: ${fname}"
fi
done
echo "[$(date)] Backup complete."

55
scripts/backup.py Normal file → Executable file
View File

@ -24,6 +24,40 @@ DB_HOST = "supabase-db"
DB_USER = "supabase_admin"
DB_NAME = "postgres"
def get_db_pass():
"""Пароль из окружения или /opt/supabase/docker/.env, иначе известное значение."""
if os.environ.get("POSTGRES_PASSWORD"):
return os.environ["POSTGRES_PASSWORD"]
try:
with open("/opt/supabase/docker/.env") as f:
for line in f:
if line.startswith("POSTGRES_PASSWORD="):
return line.split("=", 1)[1].strip()
except OSError:
pass
return "4fe80bb21c7c3d17a8d8b226adf7a479"
DB_PASS = get_db_pass()
def log_backup(status, size, size_bytes, s3_path, tables):
"""Запись об операции в public.backup_logs (SQL через stdin, не через -c)."""
sql = (
"INSERT INTO public.backup_logs "
"(backup_type, status, file_size, file_size_bytes, s3_path, tables_included) "
f"VALUES ('csv', '{status}', '{size}', {int(size_bytes)}, '{s3_path}', '{tables}');\n"
)
result = subprocess.run(
["docker", "exec", "-i", "-e", f"PGPASSWORD={DB_PASS}", DB_HOST,
"psql", "-U", DB_USER, "-d", DB_NAME, "-q", "-t", "-A"],
input=sql, capture_output=True, text=True,
)
if result.returncode != 0:
print(f"WARN: не удалось записать backup_logs: {result.stderr.strip()}", file=sys.stderr)
def get_tables():
"""Get list of public tables with data."""
result = subprocess.run(
@ -38,6 +72,7 @@ def get_tables():
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
return tables
def export_table_csv(table_name, out_dir):
"""Export a single table to CSV via psql \\copy."""
csv_path = os.path.join(out_dir, f"{table_name}.csv")
@ -73,6 +108,7 @@ def export_table_csv(table_name, out_dir):
print(f" {table_name}: {len(lines)-1} rows")
return True
def upload_to_s3(file_path, key):
"""Upload file to S3."""
s3 = boto3.client(
@ -86,13 +122,16 @@ def upload_to_s3(file_path, key):
s3.upload_file(file_path, S3_BUCKET, key)
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
def main():
date_str = datetime.now().strftime("%Y-%m-%d")
archive_name = f"supersam-backup-{date_str}.tar.gz"
s3_key = f"backups/{archive_name}"
s3_path = f"s3://{S3_BUCKET}/{s3_key}"
print(f"=== SuperSam Backup {date_str} ===")
try:
with tempfile.TemporaryDirectory() as tmpdir:
tables = get_tables()
print(f"Found {len(tables)} tables")
@ -114,13 +153,27 @@ def main():
tar.add(csv_path, arcname=f"{table}.csv")
# Get size
size_mb = os.path.getsize(archive_path) / (1024 * 1024)
size_bytes = os.path.getsize(archive_path)
size_mb = size_bytes / (1024 * 1024)
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
# Upload
upload_to_s3(archive_path, s3_key)
log_backup(
"success",
f"{size_mb:.2f} MB",
size_bytes,
s3_path,
", ".join(exported),
)
except Exception as exc: # noqa: BLE001 — лог провала, затем проброс
print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
log_backup("failed", "", 0, s3_path, f"{type(exc).__name__}: {exc}"[:200])
raise
print(f"=== Backup complete ===")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""SuperSam daily backup — export public tables to CSV, tar.gz, upload to S3."""
import csv
import io
import os
import sys
import subprocess
import tarfile
import tempfile
from datetime import datetime
import boto3
from botocore.config import Config
# S3 config
S3_ENDPOINT = "https://s3.ru1.storage.beget.cloud"
S3_KEY = "YG4MQNKAPNL65200MBUY"
S3_SECRET = "8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_BUCKET = "02f162ff4a18-supersam-s3"
# DB config
DB_HOST = "supabase-db"
DB_USER = "supabase_admin"
DB_NAME = "postgres"
def get_tables():
"""Get list of public tables with data."""
result = subprocess.run(
["docker", "exec", "-i", DB_HOST, "psql", "-U", DB_USER, "-d", DB_NAME,
"-t", "-A", "-c",
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"ERROR getting tables: {result.stderr}", file=sys.stderr)
sys.exit(1)
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
return tables
def export_table_csv(table_name, out_dir):
"""Export a single table to CSV via psql \\copy."""
csv_path = os.path.join(out_dir, f"{table_name}.csv")
# Use psql \copy (client-side) to avoid needing superuser for COPY
cmd = [
"docker", "exec", "-i", DB_HOST,
"psql", "-U", DB_USER, "-d", DB_NAME,
"-c", f"\\copy (SELECT * FROM public.{table_name}) TO '/tmp/{table_name}.csv' WITH CSV HEADER;"
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"WARN: {table_name} export failed: {result.stderr}", file=sys.stderr)
return False
# Copy from container to host
cmd2 = ["docker", "cp", f"{DB_HOST}:/tmp/{table_name}.csv", csv_path]
result2 = subprocess.run(cmd2, capture_output=True, text=True)
if result2.returncode != 0:
print(f"WARN: {table_name} docker cp failed: {result2.stderr}", file=sys.stderr)
return False
# Clean up temp file in container
subprocess.run(["docker", "exec", "-i", DB_HOST, "rm", "-f", f"/tmp/{table_name}.csv"],
capture_output=True, text=True)
# Check if file has data (more than just header)
with open(csv_path, "r") as f:
lines = f.readlines()
if len(lines) <= 1:
print(f" {table_name}: empty (skipping)")
return False
print(f" {table_name}: {len(lines)-1} rows")
return True
def upload_to_s3(file_path, key):
"""Upload file to S3."""
s3 = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_KEY,
aws_secret_access_key=S3_SECRET,
config=Config(signature_version="s3v4"),
region_name="ru-1"
)
s3.upload_file(file_path, S3_BUCKET, key)
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
def main():
date_str = datetime.now().strftime("%Y-%m-%d")
archive_name = f"supersam-backup-{date_str}.tar.gz"
s3_key = f"backups/{archive_name}"
print(f"=== SuperSam Backup {date_str} ===")
with tempfile.TemporaryDirectory() as tmpdir:
tables = get_tables()
print(f"Found {len(tables)} tables")
exported = []
for table in tables:
if export_table_csv(table, tmpdir):
exported.append(table)
if not exported:
print("No tables with data — nothing to backup")
sys.exit(0)
# Create tar.gz
archive_path = os.path.join(tmpdir, archive_name)
with tarfile.open(archive_path, "w:gz") as tar:
for table in exported:
csv_path = os.path.join(tmpdir, f"{table}.csv")
tar.add(csv_path, arcname=f"{table}.csv")
# Get size
size_mb = os.path.getsize(archive_path) / (1024 * 1024)
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
# Upload
upload_to_s3(archive_path, s3_key)
print(f"=== Backup complete ===")
if __name__ == "__main__":
main()

117
scripts/check_autocall_window.py Executable file
View File

@ -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)

View File

@ -34,6 +34,16 @@ import requests
import psycopg2
from psycopg2.extras import RealDictCursor
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
from sms_rate_limit import (
check_send_allowed, alert_due, acquire_send_lock, release_send_lock,
)
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
check_send_allowed = None
alert_due = None
acquire_send_lock = None
release_send_lock = None
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
@ -64,6 +74,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 +260,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 +271,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 +299,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 +317,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 +406,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 +431,21 @@ 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")
# --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний ---
_send_lock = False
if acquire_send_lock is not None:
_send_lock = acquire_send_lock(conn)
if not _send_lock:
log.warning("Прогон пропущен: не удалось взять блокировку отправки")
return 0
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 +469,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 +482,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 +502,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 +518,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 +526,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 +540,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 +548,30 @@ 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}с, лимит аккаунта {_acct} суммарно, "
f"страховка {_cap} на номер); "
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)")
if _send_lock and release_send_lock is not None:
release_send_lock(conn)
return sent_count
def step_check_status(conn, settings):
@ -425,7 +592,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 +638,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 +678,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 +703,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"🔧 <b>Нужен менеджер</b>\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 +781,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(

View File

@ -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 ─────────────────────────────────────────────────────────────────

View File

@ -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 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_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')
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"🔧 <b>Ручное управление</b>\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}")

View File

@ -27,6 +27,16 @@ import requests
import psycopg2
from psycopg2.extras import RealDictCursor
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
from sms_rate_limit import (
check_send_allowed, alert_due, acquire_send_lock, release_send_lock,
)
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
check_send_allowed = None
alert_due = None
acquire_send_lock = None
release_send_lock = None
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
@ -51,6 +61,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 +140,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 +167,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 +264,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 != ''
@ -277,7 +373,18 @@ def step_send_new(conn, settings):
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
# --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний ---
_send_lock = False
if acquire_send_lock is not None:
_send_lock = acquire_send_lock(conn)
if not _send_lock:
log.warning("Прогон пропущен: не удалось взять блокировку отправки")
return 0
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 +402,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 +428,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 +458,26 @@ 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}с, лимит аккаунта {_acct} суммарно, "
f"страховка {_cap} на номер); "
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)")
if _send_lock and release_send_lock is not None:
release_send_lock(conn)
return sent_count
def step_check_status(conn, settings):

192
scripts/sms_rate_limit.py Normal file
View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru).
Главное ограничение sms.ru — **не более 8 SMS за 10 минут по аккаунту
суммарно, на любые номера** (антибомбинг; при превышении отправка отклоняется
кодами 231/232). Отдельно страхуемся правилом «не более N SMS на один номер
за окно». Сейчас кампании узнают о лимите только постфактум — по коду ошибки
231/232, после чего группа помечается send_failed, а часть сообщений уже
потеряна. Этот модуль проверяет лимит ДО отправки и придерживает сообщение:
оно не теряется, а уходит в следующий запуск кампании.
Отправку всех кампаний сериализует общая блокировка (acquire_send_lock):
аккаунтный счётчик один на всех, а таймеры кампаний стартуют в одну секунду.
Правило и окно настраиваются в sms_campaign_settings (админка/БД):
sms_limit_per_number_10min — сколько SMS на один номер за окно
(по умолчанию 8; 0 = проверка выключена)
sms_limit_account_10min — сколько SMS всего за окно, по аккаунту
(боевое значение 8 = правило sms.ru;
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
# ─── Блокировка отправки (сериализация кампаний) ─────────────────────────────
SEND_LOCK_KEY = 0x53535331 # 'SSS1' — общий ключ отправки всех SMS-кампаний
LOCK_TIMEOUT_SECONDS = 120 # ждём чужой прогон не дольше двух минут
def acquire_send_lock(conn, timeout_seconds=LOCK_TIMEOUT_SECONDS):
"""Захватить общую блокировку отправки (pg_advisory_lock).
Аккаунтный лимит sms.ru общий для всех кампаний, а их таймеры стартуют в
одну и ту же секунду: без сериализации каждый процесс видит «свободно 8» и
вместе они отправят больше лимита. Блокировка — на соединении, снимается
сама при закрытии коннекта.
True — блокировка взята, можно отправлять.
False — чужой прогон не завершился за timeout_seconds: прогон пропускаем
(лучше задержка, чем блокировка аккаунта у sms.ru), повторим по
таймеру.
"""
try:
with conn.cursor() as cur:
cur.execute("SET lock_timeout = %s", (f"{int(timeout_seconds)}s",))
cur.execute("SELECT pg_advisory_lock(%s)", (SEND_LOCK_KEY,))
return True
except Exception as e: # noqa: BLE001 — недоступная блокировка не должна ломать прогон
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
log.error("Не удалось взять блокировку отправки (%s) — прогон пропущен", e)
return False
def release_send_lock(conn):
"""Снять блокировку отправки. Ошибки не критичны: снимет закрытие коннекта."""
try:
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_unlock(%s)", (SEND_LOCK_KEY,))
return True
except Exception as e: # noqa: BLE001
log.warning("Не удалось снять блокировку отправки: %s", e)
return False

View File

@ -35,6 +35,16 @@ import requests
import psycopg2
from psycopg2.extras import RealDictCursor
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
from sms_rate_limit import (
check_send_allowed, alert_due, acquire_send_lock, release_send_lock,
)
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
check_send_allowed = None
alert_due = None
acquire_send_lock = None
release_send_lock = None
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
@ -63,6 +73,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 +149,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 +186,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 +273,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 +281,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 +293,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 +310,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 +401,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 +443,23 @@ 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")
# --- Сериализация отправки: аккаунтный лимит sms.ru общий для всех кампаний ---
_send_lock = False
if acquire_send_lock is not None:
_send_lock = acquire_send_lock(conn)
if not _send_lock:
log.warning("Прогон пропущен: не удалось взять блокировку отправки")
return 0
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 +479,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 +488,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 +508,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 +532,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 +541,26 @@ 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}с, лимит аккаунта {_acct} суммарно, "
f"страховка {_cap} на номер); "
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)")
if _send_lock and release_send_lock is not None:
release_send_lock(conn)
return sent_count
def step_check_status(conn, settings):
@ -419,7 +581,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 +663,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",
@ -525,6 +687,32 @@ def step_handle_expired(conn, settings):
tg_chat,
)
def skip_pending_reminders(conn):
"""Кампания выключена -> снять ожидающие группы с очереди второй СМС.
Терминальный статус 'second_sms_skipped' = напоминание не отправлено и
отправлено не будет. next_notification_check_at очищается: группа ничего
не ждёт, а при включении кампании накопленная очередь не рассылается.
"""
with conn.cursor() as cur:
cur.execute(
"""
UPDATE order_groups
SET notification_status = 'second_sms_skipped',
next_notification_check_at = NULL
WHERE notification_status = 'first_sms_sent'
AND second_sms_sent_at IS NULL
AND delivery_status = 'pending_confirmation'
RETURNING id
"""
)
ids = [row[0] for row in cur.fetchall()]
conn.commit()
if ids:
log.info(f"Reminders skipped (campaign disabled): {len(ids)} group(s)")
return len(ids)
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
@ -539,7 +727,11 @@ def main():
f"max_attempts={settings.get('max_attempts')}")
if not settings.get("enabled", True):
log.info("Campaign disabled, exiting")
# Напоминания не ждут: если в момент отправки кампания выключена,
# ожидающие группы снимаются с очереди второй СМС в терминальный статус.
# Накопления нет — при включении кампании старые ожидания не рассылаются.
skipped = skip_pending_reminders(conn)
log.info(f"Campaign disabled, exiting (reminders skipped: {skipped})")
return
# State machine — каждый шаг быстрый, без blocking

View File

@ -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"

View File

@ -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"

View File

@ -21,6 +21,15 @@ const CAMPAIGNS = [
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
// ── SMS text templates (доставка / самовывоз) ────────────────────────────────
const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX";
const renderSmsPreview = (template, emptyLabel) => {
const text = (template || "").trim();
if (!text) return emptyLabel;
return text.replace("{link}", SMS_LINK_PREVIEW);
};
// ── Days of week ─────────────────────────────────────────────────────────────
const DAYS = [
{ num: 1, short: "Пн" },
@ -479,19 +488,43 @@ export const SmsCampaignPanel = () => {
</div>
)}
{/* ── SMS text template ──────────────────────────────────────────── */}
{/* ── SMS text templates: доставка / самовывоз ───────────────────── */}
{!isManualCampaign && (
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div>
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">📦 Доставка</div>
<textarea
value={settings.sms_text_template || ""}
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
rows={3}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
placeholder="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
Текущая длина: {(settings.sms_text_template || "").replace("{link}", SMS_LINK_PREVIEW).length} символов
</div>
</div>
<div>
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">🏪 Самовывоз</div>
<textarea
value={settings.sms_text_template_pickup || ""}
onChange={(e) => updateSetting("sms_text_template_pickup", e.target.value)}
rows={3}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
placeholder="Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
Текущая длина: {(settings.sms_text_template_pickup || "").replace("{link}", SMS_LINK_PREVIEW).length} символов
</div>
</div>
</div>
<div className="mt-2 rounded-lg bg-[var(--color-surface-strong)] px-3 py-2 text-[10px] text-[var(--color-text-muted)]">
Текст выбирается по типу отгрузки: самовывоз → правый шаблон, доставка → левый. {"{link}"} заменяется ссылкой согласования.
<div className="mt-1">📦 Доставка: {renderSmsPreview(settings.sms_text_template, "— пусто, уйдёт стандартный текст кампании")}</div>
<div>🏪 Самовывоз: {renderSmsPreview(settings.sms_text_template_pickup, "— пусто, уйдёт стандартный текст кампании")}</div>
<div className="mt-1">Если у города в «SMS по городам» задан свой текст в режиме «Уведомление» — уходит он, и тогда шаблон применяется только если в нём нет {"{link}"}.</div>
</div>
</div>
)}

View File

@ -0,0 +1,830 @@
/**
* @file SmsCampaignPanel.jsx
* @description SMS Campaign management for mega_admin.
* Campaign selector cards + settings + log table + "Проверить снова" button
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
import { SmsCampaignStats } from "./SmsCampaignStats";
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
// ── Campaigns ───────────────────────────────────────────────────────────────
const CAMPAIGNS = [
{ key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" },
{ key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" },
{ key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" },
{ key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" },
];
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
// ── Days of week ─────────────────────────────────────────────────────────────
const DAYS = [
{ num: 1, short: "Пн" },
{ num: 2, short: "Вт" },
{ num: 3, short: "Ср" },
{ num: 4, short: "Чт" },
{ num: 5, short: "Пт" },
{ num: 6, short: "Сб" },
{ num: 7, short: "Вс" },
];
// ── Status labels ────────────────────────────────────────────────────────────
const STATUS_LABELS = {
sent: "Отправлено",
checking: "Проверяется",
delivered: "Доставлено",
send_failed: "Ошибка отправки",
error: "Ошибка доставки",
expired: "Истёк срок",
limit_exceeded: "Лимит превышен",
manual_override: "Ручной режим",
};
const STATUS_TONES = {
sent: "info",
checking: "neutral",
delivered: "accent",
send_failed: "danger",
error: "danger",
expired: "warning",
limit_exceeded: "danger",
manual_override: "warning",
};
// ── SMS code labels ──────────────────────────────────────────────────────────
const SMS_CODE_LABELS = {
"100": "В очереди", "101": "Оператору", "102": "В пути",
"103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
"106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
"130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
"132": "Лимит одинаковых/день", "200": "Неправильный api_id",
"201": "Недостаточно средств", "202": "Неправильный получатель",
"230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
"232": "Лимит одинаковых/день",
};
// ── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", year: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch { return ts; }
};
const fmtPhone = (phone) => {
if (!phone) return "—";
const digits = String(phone).replace(/\D/g, "");
if (digits.length === 11 && digits.startsWith("7")) {
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
}
return phone;
};
const fmtElapsed = (ts) => {
if (!ts) return "—";
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "только что";
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
const rest = m % 60;
return `${h}ч ${rest}м назад`;
};
const parseWorkDays = (str) => {
if (!str) return new Set([1, 2, 3, 4, 5]);
return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7));
};
const serializeWorkDays = (set) => [...set].sort().join(",");
// ── Component ────────────────────────────────────────────────────────────────
export const SmsCampaignPanel = () => {
const navigate = useNavigate();
const [activeCampaign, setActiveCampaign] = useState("first_sms");
const [logs, setLogs] = useState([]);
const [manualGroups, setManualGroups] = useState([]);
const [settings, setSettings] = useState(null);
const [allSettings, setAllSettings] = useState({});
const [campaignCounts, setCampaignCounts] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all");
const [savingSettings, setSavingSettings] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [checkingIds, setCheckingIds] = useState(new Set());
const [autoRefresh, setAutoRefresh] = useState(true);
const [togglingCampaign, setTogglingCampaign] = useState(null);
const [showCityRules, setShowCityRules] = useState(false);
const [logPage, setLogPage] = useState(1);
const [manualPage, setManualPage] = useState(1);
const refreshTimer = useRef(null);
const PER_PAGE = 20;
const handleOpenGroup = useCallback((groupId) => {
if (groupId) navigate("/dashboard/group/" + groupId);
}, [navigate]);
// ── Load data ──────────────────────────────────────────────────────────────
const loadData = useCallback(async () => {
setError(null);
try {
// Load all settings for cards
const { data: allSettingsData, error: allErr } = await supabase
.from("sms_campaign_settings")
.select("*");
if (allErr) throw allErr;
const settingsMap = {};
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
setAllSettings(settingsMap);
// Load counts per campaign (for cards)
const { data: countsData, error: countsErr } = await supabase
.from("sms_campaign_log")
.select("campaign_type, status");
if (countsErr) throw countsErr;
const counts = {};
(countsData || []).forEach(r => {
if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 };
counts[r.campaign_type].total++;
if (r.status === "delivered") counts[r.campaign_type].delivered++;
if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++;
if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++;
});
// Also count manual_required groups for the manual card
const { count: manualCount, error: manualCountErr } = await supabase
.from("order_groups")
.select("id", { count: "exact", head: true })
.eq("notification_status", "manual_required");
if (!manualCountErr && manualCount != null) {
counts["manual"] = counts["manual"] || { total: 0, delivered: 0, sent: 0, errors: 0 };
counts["manual"].total = manualCount;
}
setCampaignCounts(counts);
// For "manual" campaign: load order_groups with manual_required status
if (activeCampaign === "manual") {
const { data: manualData, error: manualErr } = await supabase
.from("order_groups")
.select("id, customer_name, customer_phone, notification_status, first_sms_sent_at, second_sms_sent_at, next_notification_check_at, sms_attempts, last_sms_error, delivery_status, delivery_link, created_at, updated_at")
.eq("notification_status", "manual_required")
.order("updated_at", { ascending: false })
.limit(200);
if (manualErr) throw manualErr;
setManualGroups(manualData || []);
setLogs([]); // No SMS logs for manual campaign
} else {
setManualGroups([]);
// Load logs for active campaign
let query = supabase
.from("sms_campaign_log")
.select("*")
.eq("campaign_type", activeCampaign)
.order("created_at", { ascending: false })
.limit(200);
if (filter !== "all") query = query.eq("status", filter);
const { data: logData, error: logError } = await query;
if (logError) throw logError;
setLogs(logData || []);
}
// Load settings for active campaign
if (HAS_SETTINGS.includes(activeCampaign)) {
setSettings(settingsMap[activeCampaign] || null);
} else {
setSettings(null);
}
} catch (e) {
setError(e.message || String(e));
} finally {
setIsLoading(false);
}
}, [activeCampaign, filter]);
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
useEffect(() => {
if (autoRefresh) {
refreshTimer.current = setInterval(() => loadData(), 30000);
return () => clearInterval(refreshTimer.current);
}
}, [autoRefresh, loadData]);
// ── Save settings ──────────────────────────────────────────────────────────
const handleSaveSettings = async () => {
if (!settings) return;
setSavingSettings(true);
try {
const { id, ...updates } = settings;
updates.updated_at = new Date().toISOString();
const { error: updateError } = await supabase
.from("sms_campaign_settings")
.update(updates)
.eq("id", id);
if (updateError) throw updateError;
setSettingsSaved(true);
setTimeout(() => setSettingsSaved(false), 2000);
} catch (e) {
setError(`Ошибка сохранения: ${e.message}`);
} finally {
setSavingSettings(false);
}
};
const updateSetting = (key, value) => {
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
};
// ── Quick toggle campaign on/off from card ────────────────────────────────
const toggleCampaignEnabled = async (campaignKey) => {
const s = allSettings[campaignKey];
if (!s) return;
setTogglingCampaign(campaignKey);
const newVal = !(s.enabled ?? true);
try {
const { error: updateError } = await supabase
.from("sms_campaign_settings")
.update({ enabled: newVal, updated_at: new Date().toISOString() })
.eq("id", s.id);
if (updateError) throw updateError;
// Update local state
setAllSettings(prev => ({
...prev,
[campaignKey]: { ...prev[campaignKey], enabled: newVal }
}));
if (campaignKey === activeCampaign) {
setSettings(prev => prev ? { ...prev, enabled: newVal } : prev);
}
} catch (e) {
setError(`Ошибка: ${e.message}`);
} finally {
setTogglingCampaign(null);
}
};
const toggleWorkDay = (dayNum) => {
const current = parseWorkDays(settings?.work_days);
if (current.has(dayNum)) current.delete(dayNum);
else current.add(dayNum);
updateSetting("work_days", serializeWorkDays(current));
};
// ── Recheck ────────────────────────────────────────────────────────────────
const handleRecheck = async (logId) => {
setCheckingIds(prev => new Set([...prev, logId]));
try {
const { error: updateError } = await supabase
.from("sms_campaign_log")
.update({ needs_check: true, updated_at: new Date().toISOString() })
.eq("id", logId);
if (updateError) throw updateError;
setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l));
} catch (e) {
setError(`Ошибка: ${e.message}`);
} finally {
setTimeout(() => {
setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; });
}, 1000);
}
};
// ── Stats ──────────────────────────────────────────────────────────────────
const stats = logs.reduce((acc, log) => {
acc[log.status] = (acc[log.status] || 0) + 1;
return acc;
}, {});
const showSettings = HAS_SETTINGS.includes(activeCampaign);
const isManualCampaign = activeCampaign === "manual";
const isPaidStorageCampaign = activeCampaign === "paid_storage";
const showSmsFields = !isManualCampaign;
// ── Render ──────────────────────────────────────────────────────────────────
if (isLoading && logs.length === 0) {
return (
<Panel className="p-5">
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка…</div>
</Panel>
);
}
return (
<div className="space-y-4">
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
{/* Balance banner */}
{allSettings.first_sms?.last_balance != null && (
<Panel className="p-3">
<div className="flex items-center gap-3">
<span className="text-2xl">💰</span>
<div>
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
<div className="text-lg font-bold text-[var(--color-text)]">
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
</div>
</div>
</div>
</Panel>
)}
{/* Campaign cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 max-w-4xl">
{CAMPAIGNS.map(c => {
const isActive = activeCampaign === c.key;
const s = allSettings[c.key];
const isEnabled = s?.enabled ?? true;
const isTest = s?.test_mode ?? true;
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
const hasS = HAS_SETTINGS.includes(c.key);
const isToggling = togglingCampaign === c.key;
return (
<div
key={c.key}
className={`rounded-2xl border p-4 transition cursor-pointer ${
isActive
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
}`}
onClick={() => { setActiveCampaign(c.key); setFilter("all"); setLogPage(1); setManualPage(1); }}
>
{/* Header: icon + toggle */}
<div className="flex items-center justify-between">
<span className="text-2xl">{c.icon}</span>
{hasS && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleCampaignEnabled(c.key); }}
disabled={isToggling}
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
</button>
)}
</div>
{/* Title */}
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
{/* Status badge */}
{hasS && (
<div className="mt-1">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
isEnabled
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
}`}>
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
</span>
</div>
)}
{/* Quick stats */}
{hasS && cnt.total > 0 && (
<div className="mt-3 grid grid-cols-3 gap-1 text-center">
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">Всего</div>
<div className="text-xs font-bold text-[var(--color-text)]">{cnt.total}</div>
</div>
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">Доставл.</div>
<div className="text-xs font-bold text-[#22c55e]">{cnt.delivered}</div>
</div>
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">Ошибок</div>
<div className="text-xs font-bold text-[#ef4444]">{cnt.errors}</div>
</div>
</div>
)}
{!hasS && (
<div className="mt-2 text-[11px] text-[var(--color-text-muted)] leading-snug">В разработке</div>
)}
</div>
);
})}
</div>
{/* ── City rules button ──────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCityRules(!showCityRules)}
className={[
"rounded-2xl border px-4 py-2.5 text-sm font-medium transition flex items-center gap-2",
showCityRules
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
].join(" ")}
>
🏙️ SMS по городам
</button>
</div>
{/* ── City profiles panel ────────────────────────────────────────── */}
{showCityRules && <SmsCityProfilesPanel />}
{error && (
<Panel className="p-3">
<div className="text-xs text-[var(--color-danger)]">{error}</div>
<button onClick={() => { setError(null); loadData(); }} className="mt-2 text-xs text-[var(--color-accent)]">Повторить</button>
</Panel>
)}
{/* ── Statistics ────────────────────────────────────────────────────── */}
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
{/* ── Settings ──────────────────────────────────────────────────────── */}
{!showCityRules && showSettings && settings && (
<Panel className="p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}
</h3>
{settingsSaved && <span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>}
</div>
{/* Test / Production toggle — только для SMS-кампаний */}
{!isManualCampaign && (
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">
{settings.test_mode ? "🧪 Тестовый режим" : "🚀 Боевой режим"}
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
{settings.test_mode
? `SMS только на: ${settings.test_phone || "—"}`
: "SMS реальным клиентам из базы"}
</div>
</div>
<button
type="button"
onClick={() => updateSetting("test_mode", !settings.test_mode)}
className={`relative h-7 w-12 rounded-full transition ${settings.test_mode ? "bg-[var(--color-warning)]" : "bg-[var(--color-accent)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.test_mode ? "left-0.5" : "left-[22px]"}`} />
</button>
</label>
{settings.test_mode && (
<div className="mt-3">
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
</div>
)}
</div>
)}
{/* ── SMS text template ──────────────────────────────────────────── */}
{!isManualCampaign && (
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
<textarea
value={settings.sms_text_template || ""}
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
rows={3}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
</div>
</div>
)}
{/* ── Интервалы ─────────────────────────────────────────────────── */}
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{!isManualCampaign && (
<>
<SettingField label="Пауза между проверками (сек)" value={settings.wait_between_checks_seconds} onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
<SettingField label="Макс. время ожидания (мин)" value={settings.max_check_duration_minutes} onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
<SettingField label="Макс. попыток отправки" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)} />
{!isPaidStorageCampaign && (
<SettingField label="Вторая SMS через (часов)" value={settings.second_sms_delay_hours} onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
)}
</>
)}
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
</div>
{/* ── Время работы ────────────────────────────────────────────── */}
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">
{isManualCampaign ? "⏰ Время проверки" : "⏰ Время отправки SMS"}
</div>
{/* Часы */}
<div className="mb-3 flex items-center gap-2">
<select
value={settings.work_hours_start ?? 8}
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-xs text-[var(--color-text-muted)]">—</span>
<select
value={settings.work_hours_end ?? 21}
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
</div>
{/* Дни недели — таблетки */}
<div className="flex flex-wrap gap-2">
{DAYS.map(d => {
const activeDays = parseWorkDays(settings.work_days);
const isOn = activeDays.has(d.num);
return (
<button
key={d.num}
type="button"
onClick={() => toggleWorkDay(d.num)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
isOn
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{d.short}
</button>
);
})}
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Проверка статусов работает круглосуточно. Отправка — только в выбранные часы и дни.
</div>
</div>
{/* ── Вкл/выкл + сохранить ──────────────────────────────────────── */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
</button>
<span className="text-xs font-medium text-[var(--color-text)]">
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
</span>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{savingSettings ? "Сохранение…" : "Сохранить"}
</button>
</div>
</Panel>
)}
{/* ── Not implemented ──────────────────────────────────────────────── */}
{!showCityRules && !showSettings && (
<Panel className="p-5">
<div className="text-sm text-[var(--color-text-muted)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
</div>
</Panel>
)}
{/* ── Manual campaign: groups table ─────────────────────────────────── */}
{isManualCampaign ? (
<>
<Panel className="p-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-text-muted)]">В ручном управлении: {manualGroups.length}</span>
</div>
</Panel>
<Panel className="p-0">
<div className="overflow-x-auto">
<div className="min-w-[800px]">
<div className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Клиент</div>
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">1-я SMS</div>
<div className="px-3 py-1.5 font-medium">2-я SMS</div>
<div className="px-3 py-1.5 font-medium">Обновлён</div>
</div>
{manualGroups.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
) : (
(() => {
const start = (manualPage - 1) * PER_PAGE;
const pageGroups = manualGroups.slice(start, start + PER_PAGE);
return pageGroups.map((group) => (
<div
key={group.id}
onClick={() => handleOpenGroup(group.id)}
className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
>
<div className="px-3 py-1.5 text-[var(--color-text)]">
<div className="font-medium">{group.customer_name || "—"}</div>
{group.last_sms_error && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{group.last_sms_error.slice(0, 60)}</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{fmtPhone(group.customer_phone)}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{group.first_sms_sent_at ? fmtTime(group.first_sms_sent_at) : "—"}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{group.second_sms_sent_at ? fmtTime(group.second_sms_sent_at) : <span className="text-[var(--color-danger)]">не отправлена</span>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{fmtTime(group.updated_at)}
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
</div>
</div>
));
})()
)}
</div>
</div>
</Panel>
{/* ── Pagination: manual groups ─────────────────────────────────── */}
{manualGroups.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setManualPage(p => Math.max(1, p - 1))}
disabled={manualPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>← Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {manualPage} / {Math.ceil(manualGroups.length / PER_PAGE)} · {manualGroups.length} записей
</span>
<button
onClick={() => setManualPage(p => Math.min(Math.ceil(manualGroups.length / PER_PAGE), p + 1))}
disabled={manualPage >= Math.ceil(manualGroups.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд →</button>
</div>
)}
</>
) : (
<>
{/* ── Stats ─────────────────────────────────────────────────────────── */}
<Panel className="p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
{Object.entries(STATUS_LABELS).map(([status, label]) => {
const count = stats[status] || 0;
if (count === 0) return null;
return <Badge key={status} tone={STATUS_TONES[status] || "neutral"}>{label}: {count}</Badge>;
})}
</div>
</Panel>
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<FilterButton active={filter === "all"} onClick={() => { setFilter("all"); setLogPage(1); }}>Все ({logs.length})</FilterButton>
{Object.entries(STATUS_LABELS).map(([status, label]) => {
const count = stats[status] || 0;
if (count === 0) return null;
return <FilterButton key={status} active={filter === status} onClick={() => { setFilter(status); setLogPage(1); }}>{label} ({count})</FilterButton>;
})}
</div>
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} className="h-3.5 w-3.5 rounded border-[var(--color-border)]" />
Авто-обновление (30с)
</label>
</div>
{/* ── Log table ──────────────────────────────────────────────────────── */}
<Panel className="p-0">
<div className="overflow-x-auto">
<div className="min-w-[900px]">
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">SMS ID</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Код</div>
<div className="px-3 py-1.5 font-medium">Поп.</div>
<div className="px-3 py-1.5 font-medium">Создано</div>
<div className="px-3 py-1.5 font-medium">Действие</div>
</div>
{logs.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
) : (
(() => {
const totalPages = Math.ceil(logs.length / PER_PAGE);
const start = (logPage - 1) * PER_PAGE;
const pageLogs = logs.slice(start, start + PER_PAGE);
return pageLogs.map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const isTestMode = settings?.test_mode;
return (
<div
key={entry.id}
onClick={() => handleOpenGroup(entry.order_group_id)}
className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
>
<div className="px-3 py-1.5 text-[var(--color-text)]">
{fmtPhone(entry.customer_phone)}
{isTestMode && <div className="text-[10px] text-[var(--color-warning)]">🧪 на тест. номер</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.sms_id || "—"}</div>
<div className="px-3 py-1.5">
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>{STATUS_LABELS[entry.status] || entry.status}</Badge>
{entry.needs_check && <div className="mt-0.5 text-[10px] text-[var(--color-accent)]">⟳ в очереди</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]" title={SMS_CODE_LABELS[entry.sms_code] || ""}>
{entry.sms_code || "—"}
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && <div className="text-[10px]">{SMS_CODE_LABELS[entry.sms_code]}</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.attempts || 0}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{fmtTime(entry.created_at)}
<div className="text-[10px]">{fmtElapsed(entry.created_at)}</div>
{entry.error_message && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>}
</div>
<div className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
{canRecheck ? (
<button
onClick={() => handleRecheck(entry.id)}
disabled={isChecking || entry.needs_check}
className="rounded-lg border border-[var(--color-border)] px-2 py-1 text-[11px] font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>
{isChecking ? "…" : entry.needs_check ? "⟳ В очереди" : "↻ Проверить"}
</button>
) : (
<span className="text-[10px] text-[var(--color-text-muted)]">→ к доставке</span>
)}
</div>
</div>
);
});
})()
)}
</div>
</div>
</Panel>
{/* ── Pagination: logs ─────────────────────────────────────────────── */}
{logs.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setLogPage(p => Math.max(1, p - 1))}
disabled={logPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>← Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей
</span>
<button
onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / PER_PAGE), p + 1))}
disabled={logPage >= Math.ceil(logs.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд →</button>
</div>
)}
<div className="flex justify-end">
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
↻ Обновить
</button>
</div>
</>
)}
</div>
);
};
// ── Helpers ──────────────────────────────────────────────────────────────────
const SettingField = ({ label, value, onChange }) => (
<label className="block">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</label>
);
const FilterButton = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
active
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{children}
</button>
);

View File

@ -0,0 +1,123 @@
CREATE OR REPLACE FUNCTION public.log_order_group_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_user_id uuid;
v_changes jsonb := '{}'::jsonb;
v_action text;
v_source text;
BEGIN
-- Determine who made the change
v_user_id := auth.uid();
v_source := CASE WHEN v_user_id IS NOT NULL THEN 'user' ELSE 'system' END;
-- Track only meaningful field changes
IF NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN
v_changes := v_changes || jsonb_build_object(
'notification_status',
jsonb_build_object('old', OLD.notification_status, 'new', NEW.notification_status)
);
END IF;
IF NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN
v_changes := v_changes || jsonb_build_object(
'delivery_status',
jsonb_build_object('old', OLD.delivery_status, 'new', NEW.delivery_status)
);
END IF;
IF NEW.status IS DISTINCT FROM OLD.status THEN
v_changes := v_changes || jsonb_build_object(
'status',
jsonb_build_object('old', OLD.status, 'new', NEW.status)
);
END IF;
IF NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN
v_changes := v_changes || jsonb_build_object(
'assigned_driver_id',
jsonb_build_object('old', OLD.assigned_driver_id, 'new', NEW.assigned_driver_id)
);
END IF;
IF NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN
v_changes := v_changes || jsonb_build_object(
'delivery_link',
jsonb_build_object('old', OLD.delivery_link, 'new', NEW.delivery_link)
);
END IF;
IF NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at THEN
v_changes := v_changes || jsonb_build_object(
'first_sms_sent_at',
jsonb_build_object('old', OLD.first_sms_sent_at, 'new', NEW.first_sms_sent_at)
);
END IF;
IF NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at THEN
v_changes := v_changes || jsonb_build_object(
'second_sms_sent_at',
jsonb_build_object('old', OLD.second_sms_sent_at, 'new', NEW.second_sms_sent_at)
);
END IF;
IF NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error THEN
v_changes := v_changes || jsonb_build_object(
'last_sms_error',
jsonb_build_object('old', OLD.last_sms_error, 'new', NEW.last_sms_error)
);
END IF;
-- Only insert if something meaningful changed
IF v_changes = '{}'::jsonb THEN
RETURN NEW;
END IF;
-- Determine action label
v_action := CASE
WHEN NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN 'delivery_status_change'
WHEN NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN 'notification_status_change'
WHEN NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN 'driver_assignment_change'
WHEN NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN 'delivery_link_change'
ELSE 'field_change'
END;
INSERT INTO order_history (order_group_id, action, old_status, new_status, user_id, metadata)
VALUES (
NEW.id,
v_action,
OLD.delivery_status,
NEW.delivery_status,
v_user_id,
jsonb_build_object('changes', v_changes, 'source', v_source, 'group_key', NEW.group_key)
);
RETURN NEW;
END;
$$;
-- Drop old trigger if exists
DROP TRIGGER IF EXISTS log_order_group_change_trigger ON public.order_groups;
-- Create trigger AFTER UPDATE (only when fields actually change)
CREATE TRIGGER log_order_group_change_trigger
AFTER UPDATE ON public.order_groups
FOR EACH ROW
WHEN (
NEW.notification_status IS DISTINCT FROM OLD.notification_status
OR NEW.delivery_status IS DISTINCT FROM OLD.delivery_status
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id
OR NEW.delivery_link IS DISTINCT FROM OLD.delivery_link
OR NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at
OR NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at
OR NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error
)
EXECUTE FUNCTION public.log_order_group_change();
-- Grant access
GRANT SELECT ON order_history TO authenticated;
GRANT INSERT ON order_history TO authenticated;

View File

@ -0,0 +1,24 @@
-- Drop old policies that reference orders table
DROP POLICY IF EXISTS "history select by order role" ON order_history;
DROP POLICY IF EXISTS "history insert workflow" ON order_history;
-- New SELECT policy: users can read history for order_groups they have access to
CREATE POLICY "order_history_select" ON order_history
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM order_groups og
WHERE og.id = order_history.order_group_id
AND (
current_role_name() = ANY (ARRAY['admin', 'mega_admin', 'production_lead'])
OR (current_role_name() = 'manager')
OR (current_role_name() = 'logistician')
OR (current_role_name() = 'driver' AND og.assigned_driver_id = auth.uid())
)
)
);
-- INSERT policy: only trigger (SECURITY DEFINER) inserts, users don't insert directly
CREATE POLICY "order_history_insert_trigger" ON order_history
FOR INSERT TO authenticated
WITH CHECK (true);

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Simple webhook listener for Gitea push events to trigger supersam deploy."""
import json
import hashlib
import hmac
import subprocess
import logging
from http.server import HTTPServer, BaseHTTPRequestHandler
SECRET = '1032ef91aacd726907bb72c023813c6827f56354902cc7b30e72626690c6d51d'
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('webhook')
class WebhookHandler(BaseHTTPRequestHandler):
def verify_signature(self, body):
signature = self.headers.get('X-Gitea-Signature', '')
if not signature:
return False
computed = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, computed)
def do_POST(self):
if self.path == '/webhook/supersam':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length else b''
if not self.verify_signature(body):
self.send_response(403)
self.end_headers()
self.wfile.write(b'{"error": "invalid signature"}')
return
try:
payload = json.loads(body) if body else {}
ref = payload.get('ref', '')
if ref == 'refs/heads/main':
logger.info('Push to main detected, starting deploy...')
result = subprocess.run(
['/opt/supersam/deploy.sh'],
capture_output=True, text=True, timeout=600
)
logger.info('Deploy exit code: %s', result.returncode)
if result.returncode != 0:
logger.error('Deploy stderr: %s', result.stderr[-2000:])
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status': 'deployed', 'exit_code': result.returncode}).encode())
else:
logger.info('Ignoring push to %s', ref)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"status": "ignored"}')
except Exception as e:
logger.error('Error: %s', e)
self.send_response(500)
self.end_headers()
self.wfile.write(json.dumps({'error': str(e)}).encode())
else:
self.send_response(404)
self.end_headers()
def do_GET(self):
if self.path == '/health':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"status": "ok"}')
else:
self.send_response(404)
self.end_headers()
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 9000), WebhookHandler)
logger.info('Webhook listener on http://0.0.0.0:9000')
server.serve_forever()