Compare commits
4 Commits
eb1b39e035
...
b713bdcf52
| Author | SHA1 | Date |
|---|---|---|
|
|
b713bdcf52 | |
|
|
5170c4fc93 | |
|
|
d9327f320c | |
|
|
f55b43e716 |
38
deploy.sh
38
deploy.sh
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
services:
|
||||
supersam-app:
|
||||
build:
|
||||
context: /opt/supersam
|
||||
context: /opt/supersam-dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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()
|
||||
|
|
@ -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."
|
||||
|
|
@ -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."
|
||||
|
|
@ -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."
|
||||
|
|
@ -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."
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue