SMS: проактивный лимитер sms.ru (8 SMS на номер за 10 минут) перед отправкой
This commit is contained in:
parent
eb1b39e035
commit
f55b43e716
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()
|
||||||
|
|
@ -15,6 +15,7 @@ S3_BUCKET="02f162ff4a18-supersam-s3"
|
||||||
S3_PREFIX="backups"
|
S3_PREFIX="backups"
|
||||||
|
|
||||||
SOURCE_DIR="/opt/supersam"
|
SOURCE_DIR="/opt/supersam"
|
||||||
|
SOURCE_DIR_DEV="/opt/supersam-dev"
|
||||||
RETENTION_DAYS=30
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
# ── Runtime ───────────────────────────────────────────────────────────────
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -27,14 +28,15 @@ echo "[$(date)] Starting code backup..."
|
||||||
|
|
||||||
# ── Archive ──────────────────────────────────────────────────────────────
|
# ── Archive ──────────────────────────────────────────────────────────────
|
||||||
echo "[$(date)] Archiving ${SOURCE_DIR}..."
|
echo "[$(date)] Archiving ${SOURCE_DIR}..."
|
||||||
|
# Рантайм-чекаут + актуальный dev-чекаут (источник прод-фронта)
|
||||||
tar -czf "${ARCHIVE_FILE}" \
|
tar -czf "${ARCHIVE_FILE}" \
|
||||||
--directory="${SOURCE_DIR}" \
|
--directory="/" \
|
||||||
--exclude="node_modules" \
|
--exclude="node_modules" \
|
||||||
--exclude=".git" \
|
--exclude=".git" \
|
||||||
--exclude="dist" \
|
--exclude="dist" \
|
||||||
--exclude="volumes" \
|
--exclude="volumes" \
|
||||||
--exclude="__pycache__" \
|
--exclude="__pycache__" \
|
||||||
. 2>/dev/null
|
"${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null
|
||||||
|
|
||||||
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
|
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
|
||||||
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
|
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
|
||||||
|
|
|
||||||
|
|
@ -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,12 @@ import requests
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
|
||||||
|
from sms_rate_limit import check_send_allowed, alert_due
|
||||||
|
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
|
||||||
|
check_send_allowed = None
|
||||||
|
alert_due = None
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
|
@ -64,6 +70,84 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
||||||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
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(
|
logging.basicConfig(
|
||||||
|
|
@ -172,7 +256,7 @@ def send_telegram(message, chat_id):
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
def get_groups_to_send(conn, max_attempts):
|
||||||
"""Группы, которым ещё не отправили SMS.
|
"""Группы, которым ещё не отправили SMS.
|
||||||
|
|
||||||
Режимы (из sms_city_profiles):
|
Режимы (из sms_city_profiles):
|
||||||
|
|
@ -183,7 +267,8 @@ def get_groups_to_send(conn):
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
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.delivery_type, og.town, og.sms_attempts,
|
||||||
|
og.order_numbers, og.pickup_code,
|
||||||
-- Профиль города (fallback на дефолтные значения если нет записи)
|
-- Профиль города (fallback на дефолтные значения если нет записи)
|
||||||
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
|
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
|
||||||
COALESCE(scp.delivery_text, '') as delivery_text,
|
COALESCE(scp.delivery_text, '') as delivery_text,
|
||||||
|
|
@ -210,6 +295,16 @@ def get_groups_to_send(conn):
|
||||||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
||||||
AND COALESCE(scp.sms_enabled, true) = true
|
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 часа
|
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
@ -218,8 +313,15 @@ def get_groups_to_send(conn):
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND scl.status IN ('sent', 'checking')
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
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
|
ORDER BY og.created_at ASC
|
||||||
""")
|
""", (max_attempts,))
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
|
@ -300,6 +402,22 @@ def update_order_group(conn, group_id, fields):
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
conn.commit()
|
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):
|
def step_send_new(conn, settings):
|
||||||
|
|
@ -309,10 +427,13 @@ def step_send_new(conn, settings):
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
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")
|
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
limited_count = 0
|
||||||
|
limited_last = None
|
||||||
|
limited_info = None
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_id = str(group["id"])
|
group_id = str(group["id"])
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
|
@ -336,15 +457,12 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Формируем текст SMS по режиму
|
# Формируем текст SMS по типу отгрузки (доставка / самовывоз) и режиму
|
||||||
|
sms_text = build_sms_text(group, settings)
|
||||||
if sms_mode == "notification":
|
if sms_mode == "notification":
|
||||||
# Уведомление без ссылки на согласование
|
log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}): {sms_text!r}")
|
||||||
sms_text = custom_text if custom_text else "Ваш заказ готов."
|
|
||||||
log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link")
|
|
||||||
else:
|
else:
|
||||||
# Стандартный флоу — ссылка на согласование
|
log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type}): {sms_text!r}")
|
||||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
|
||||||
log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})")
|
|
||||||
|
|
||||||
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||||
send_phone = phone
|
send_phone = phone
|
||||||
|
|
@ -352,7 +470,19 @@ def step_send_new(conn, settings):
|
||||||
send_phone = settings.get("test_phone", "79788382260")
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
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})")
|
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)
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
if sms_id:
|
if sms_id:
|
||||||
|
|
@ -360,11 +490,13 @@ def step_send_new(conn, settings):
|
||||||
campaign_type="first_sms",
|
campaign_type="first_sms",
|
||||||
order_group_id=group_id,
|
order_group_id=group_id,
|
||||||
customer_phone=phone,
|
customer_phone=phone,
|
||||||
|
sent_to=send_phone,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
sms_id=sms_id,
|
sms_id=sms_id,
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="sent",
|
status="sent",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
||||||
if sms_mode == "notification":
|
if sms_mode == "notification":
|
||||||
|
|
@ -374,6 +506,7 @@ def step_send_new(conn, settings):
|
||||||
"sms_sent_at": "NOW()",
|
"sms_sent_at": "NOW()",
|
||||||
"first_sms_sent_at": "NOW()",
|
"first_sms_sent_at": "NOW()",
|
||||||
"status": "first_sms_sent",
|
"status": "first_sms_sent",
|
||||||
|
"sms_attempts": attempt_no,
|
||||||
})
|
})
|
||||||
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
|
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
|
||||||
else:
|
else:
|
||||||
|
|
@ -381,6 +514,7 @@ def step_send_new(conn, settings):
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "sms_sending",
|
"notification_status": "sms_sending",
|
||||||
"sms_sent_at": "NOW()",
|
"sms_sent_at": "NOW()",
|
||||||
|
"sms_attempts": attempt_no,
|
||||||
})
|
})
|
||||||
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
|
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
@ -394,7 +528,7 @@ def step_send_new(conn, settings):
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="send_failed",
|
status="send_failed",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
error_message=error,
|
error_message=error,
|
||||||
)
|
)
|
||||||
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
||||||
|
|
@ -402,9 +536,26 @@ def step_send_new(conn, settings):
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "send_failed",
|
||||||
"last_sms_error": error[:200],
|
"last_sms_error": error[:200],
|
||||||
|
"sms_attempts": attempt_no,
|
||||||
})
|
})
|
||||||
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||||||
|
|
||||||
|
if limited_count:
|
||||||
|
_win = (limited_info or {}).get("window")
|
||||||
|
_cap = (limited_info or {}).get("per_number")
|
||||||
|
_acct = (limited_info or {}).get("account")
|
||||||
|
msg = (
|
||||||
|
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
||||||
|
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
|
||||||
|
f"причина: {limited_last}; "
|
||||||
|
f"сообщения не потеряны — уйдут в следующий запуск."
|
||||||
|
)
|
||||||
|
log.warning("Rate limit held %s SMS: %s", limited_count, limited_last)
|
||||||
|
if alert_due is not None and alert_due(conn, settings):
|
||||||
|
send_telegram(msg, tg_chat)
|
||||||
|
else:
|
||||||
|
log.info("Rate limit Telegram alert suppressed (throttled)")
|
||||||
|
|
||||||
return sent_count
|
return sent_count
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
def step_check_status(conn, settings):
|
||||||
|
|
@ -425,7 +576,7 @@ def step_check_status(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts") or 1
|
||||||
was_needs_check = item.get("needs_check", False)
|
was_needs_check = item.get("needs_check", False)
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
@ -471,9 +622,11 @@ def step_check_status(conn, settings):
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "manual_required",
|
||||||
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
"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)
|
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
elif code in LIMIT_ERROR_CODES:
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
|
@ -509,7 +662,7 @@ def step_handle_expired(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts") or 1
|
||||||
delivery_link = item.get("delivery_link", "")
|
delivery_link = item.get("delivery_link", "")
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
|
|
@ -534,6 +687,63 @@ def step_handle_expired(conn, settings):
|
||||||
tg_chat,
|
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 ────────────────────────────────────────────────────────────────────
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -555,8 +765,9 @@ def main():
|
||||||
sent = step_send_new(conn, settings)
|
sent = step_send_new(conn, settings)
|
||||||
delivered = step_check_status(conn, settings)
|
delivered = step_check_status(conn, settings)
|
||||||
step_handle_expired(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:
|
if sent > 0 or delivered > 0:
|
||||||
send_telegram(
|
send_telegram(
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,22 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
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):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||||
|
|
@ -99,7 +115,8 @@ def load_settings(conn):
|
||||||
"test_mode": True,
|
"test_mode": True,
|
||||||
"test_phone": "79788382260",
|
"test_phone": "79788382260",
|
||||||
}
|
}
|
||||||
return dict(row)
|
settings = dict(row)
|
||||||
|
return apply_schedule_work_days(conn, settings)
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine)
|
||||||
Заменяет n8n workflow "Ручное управление"
|
Заменяет n8n workflow "Ручное управление"
|
||||||
|
|
||||||
Логика:
|
Логика:
|
||||||
Если после второй SMS (notification_status = 'second_sms_sent')
|
Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч)
|
||||||
прошло auto_manual_after_hours и клиент не согласовал
|
после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и
|
||||||
(delivery_status = 'pending_confirmation'):
|
(delivery_status = 'pending_confirmation'):
|
||||||
→ notification_status = 'manual_required'
|
→ notification_status = 'manual_required'
|
||||||
→ delivery_status = 'manual_confirmation_required'
|
→ delivery_status = 'manual_confirmation_required'
|
||||||
→ next_notification_check_at = +3 мин
|
|
||||||
→ Telegram-уведомление
|
→ Telegram-уведомление
|
||||||
|
|
||||||
|
Ожидание считаем только по тем каналам, что реально включены в системе
|
||||||
|
(load_channels):
|
||||||
|
• второе приглашение включено → ждём, пока оно уйдёт;
|
||||||
|
• автодозвон включён и номеру звонить можно → ждём звонок, отсчёт
|
||||||
|
начнётся от него (позвонили позже — ожидание начинается заново);
|
||||||
|
• канал выключен / звонить нельзя (не звонить, гео, исключение) —
|
||||||
|
не ждём его и переводим по сроку ожидания.
|
||||||
|
|
||||||
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
||||||
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
||||||
"""
|
"""
|
||||||
|
|
@ -61,13 +68,30 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
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):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
|
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 ────────────────────────────────────────────────────────────────
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -94,53 +118,129 @@ def is_within_work_hours(settings):
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_manual(conn):
|
def get_groups_to_manual(conn, channels=None, manual_after_hours=3):
|
||||||
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
"""Группы, где клиент не выбрал дату/время и пора в ручную работу.
|
||||||
|
|
||||||
Сценарии:
|
Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого
|
||||||
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 без доставки → зависшая группа
|
• второе приглашение включено → ждём, пока оно уйдёт;
|
||||||
|
• автодозвон включён и этому номеру звонить можно → ждём звонка;
|
||||||
|
• канал выключен (или звонить нельзя: не звонить/гео/исключение) —
|
||||||
|
не ждём его и переводим в ручную работу по сроку ожидания.
|
||||||
|
|
||||||
|
Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие
|
||||||
|
заказы в системе ведёт только менеджер, автоматика их не трогает.
|
||||||
"""
|
"""
|
||||||
|
channels = channels or {}
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
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,
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
og.notification_status, og.delivery_link,
|
og.notification_status, og.delivery_link,
|
||||||
og.first_sms_sent_at, og.second_sms_sent_at,
|
og.first_sms_sent_at, og.second_sms_sent_at,
|
||||||
og.sms_sent_at, og.next_notification_check_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
|
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'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
AND (
|
-- Клиент уже выбрал дату/время — в ручную не переводим
|
||||||
-- 1. second_sms_sent + срок истёк
|
AND og.delivery_date IS NULL
|
||||||
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
-- Контакта не было — отсчитывать не от чего
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
|
AND c.last_contact_at > TIMESTAMP 'epoch'
|
||||||
-- 2. first_sms_sent + срок истёк (>3h после отправки)
|
-- Срок ожидания истёк от последнего контакта
|
||||||
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
|
AND NOW() - c.last_contact_at >= make_interval(hours => %s)
|
||||||
-- Иначе second_campaign не успеет отправить вторую SMS
|
-- Второе приглашение ждём, только если канал включён
|
||||||
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
|
AND (NOT %s OR og.second_sms_sent_at IS NOT NULL)
|
||||||
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 (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call)
|
||||||
AND og.second_sms_sent_at IS NOT NULL)
|
-- Гео-исключения — общие для SMS и ручного управления
|
||||||
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
AND NOT EXISTS (
|
||||||
-- FIX-4: grace period 30 мин — manual не крадёт свежие link_ready до first_campaign
|
SELECT 1 FROM sms_city_rules r
|
||||||
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
WHERE r.city = og.town
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW() - INTERVAL '30 minutes')
|
AND r.enabled = false
|
||||||
AND NOT EXISTS (
|
AND r.campaign_type IN ('*', 'manual')
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
WHERE scl.order_group_id = og.id
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
AND scl.status IN ('sent', 'checking')
|
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
|
||||||
))
|
|
||||||
-- 4. sms_sending/checking + >3h без доставки (зависло)
|
|
||||||
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
|
|
||||||
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
|
|
||||||
)
|
)
|
||||||
ORDER BY og.created_at ASC
|
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()]
|
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):
|
def update_order_group(conn, group_id, fields):
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
set_parts = []
|
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)
|
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)
|
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")
|
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
|
moved = 0
|
||||||
|
dry_run = os.environ.get("DRY_RUN") == "1"
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_id = str(group["id"])
|
group_id = str(group["id"])
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
phone = group.get("customer_phone", "")
|
phone = group.get("customer_phone", "")
|
||||||
delivery_link = group.get("delivery_link", "")
|
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, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "manual_required",
|
"notification_status": "manual_required",
|
||||||
"delivery_status": "manual_confirmation_required",
|
"delivery_status": "manual_confirmation_required",
|
||||||
"sms_attempts": 2,
|
# Попытки НЕ подменяем: показываем столько, сколько реально отправлено
|
||||||
|
"sms_attempts": attempts,
|
||||||
"last_sms_error": None,
|
"last_sms_error": None,
|
||||||
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
# Дальше ждать нечего — проверки по этому заказу закончились
|
||||||
|
"next_notification_check_at": None,
|
||||||
"status": "manual_required",
|
"status": "manual_required",
|
||||||
})
|
})
|
||||||
|
|
||||||
send_telegram(
|
send_telegram(
|
||||||
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
||||||
f"Клиент не согласовал доставку после двух SMS\n"
|
f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта "
|
||||||
|
f"({'звонки' if channels.get('autocall') else 'SMS'})\n"
|
||||||
f"Ссылка: {delivery_link}",
|
f"Ссылка: {delivery_link}",
|
||||||
tg_chat,
|
tg_chat,
|
||||||
)
|
)
|
||||||
|
|
@ -247,8 +367,9 @@ def main():
|
||||||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
log.info("RESTART: manual campaign — will process all eligible groups now")
|
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}")
|
log.info(f"Run summary: moved_to_manual={moved}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ import requests
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
|
||||||
|
from sms_rate_limit import check_send_allowed, alert_due
|
||||||
|
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
|
||||||
|
check_send_allowed = None
|
||||||
|
alert_due = None
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
|
@ -51,6 +57,65 @@ DELIVERED_CODE = "103"
|
||||||
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
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(
|
logging.basicConfig(
|
||||||
|
|
@ -71,6 +136,22 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
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):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
|
@ -82,7 +163,8 @@ def load_settings(conn):
|
||||||
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
|
"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",
|
"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 ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -178,11 +260,21 @@ def get_groups_to_send(conn):
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
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.paid_storage_at
|
og.paid_storage_at, og.delivery_type, og.town,
|
||||||
|
og.order_numbers, og.pickup_code
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.delivery_status = 'paid_storage'
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
AND og.paid_storage_at IS NOT NULL
|
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 COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
|
|
@ -278,6 +370,9 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
limited_count = 0
|
||||||
|
limited_last = None
|
||||||
|
limited_info = None
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_id = str(group["id"])
|
group_id = str(group["id"])
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
|
@ -295,17 +390,24 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
|
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sms_text = (
|
sms_text = build_sms_text(group, settings)
|
||||||
f"Ваш заказ переведён на платное хранение. "
|
log.info(f"Group {group_id}: paid_storage SMS ({group.get('delivery_type') or 'delivery'}): {sms_text!r}")
|
||||||
f"Стоимость: 300 ₽/день. "
|
|
||||||
f"Заберите заказ или согласуйте доставку: {delivery_link}"
|
|
||||||
)
|
|
||||||
|
|
||||||
send_phone = phone
|
send_phone = phone
|
||||||
if settings.get("test_mode", True):
|
if settings.get("test_mode", True):
|
||||||
send_phone = settings.get("test_phone", "79788382260")
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
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})")
|
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)
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
|
|
@ -314,6 +416,8 @@ def step_send_new(conn, settings):
|
||||||
campaign_type=CAMPAIGN_TYPE,
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
order_group_id=group_id,
|
order_group_id=group_id,
|
||||||
customer_phone=phone,
|
customer_phone=phone,
|
||||||
|
sent_to=send_phone,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
sms_id=sms_id,
|
sms_id=sms_id,
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="sent",
|
status="sent",
|
||||||
|
|
@ -342,6 +446,22 @@ def step_send_new(conn, settings):
|
||||||
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
|
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
|
||||||
send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
|
send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
|
if limited_count:
|
||||||
|
_win = (limited_info or {}).get("window")
|
||||||
|
_cap = (limited_info or {}).get("per_number")
|
||||||
|
_acct = (limited_info or {}).get("account")
|
||||||
|
msg = (
|
||||||
|
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
||||||
|
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
|
||||||
|
f"причина: {limited_last}; "
|
||||||
|
f"сообщения не потеряны — уйдут в следующий запуск."
|
||||||
|
)
|
||||||
|
log.warning("Rate limit held %s SMS: %s", limited_count, limited_last)
|
||||||
|
if alert_due is not None and alert_due(conn, settings):
|
||||||
|
send_telegram(msg, tg_chat)
|
||||||
|
else:
|
||||||
|
log.info("Rate limit Telegram alert suppressed (throttled)")
|
||||||
|
|
||||||
return sent_count
|
return sent_count
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
def step_check_status(conn, settings):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru).
|
||||||
|
|
||||||
|
sms.ru блокирует номер/аккаунт, если на ОДИН номер уходит слишком много SMS
|
||||||
|
(«бомбинг»): по умолчанию **не более 8 сообщений на номер за 10 минут**.
|
||||||
|
Сейчас кампании узнают об этом только постфактум — по коду ошибки 231/232,
|
||||||
|
после чего группа помечается send_failed, а часть сообщений уже потеряна.
|
||||||
|
Этот модуль проверяет лимит ДО отправки и придерживает сообщение: оно не
|
||||||
|
теряется, а уходит в следующий запуск кампании.
|
||||||
|
|
||||||
|
Правило и окно настраиваются в sms_campaign_settings (админка/БД):
|
||||||
|
sms_limit_per_number_10min — сколько SMS на один номер за окно
|
||||||
|
(по умолчанию 8; 0 = проверка выключена)
|
||||||
|
sms_limit_account_10min — сколько SMS всего за окно
|
||||||
|
(по умолчанию 0 = выключено)
|
||||||
|
sms_rate_window_seconds — окно в секундах (по умолчанию 600)
|
||||||
|
|
||||||
|
Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru
|
||||||
|
(sms_id IS NOT NULL) в пределах окна. Получатель берётся как
|
||||||
|
COALESCE(NULLIF(sent_to,''), customer_phone) — то, кому SMS ушла на самом деле;
|
||||||
|
в тестовом режиме все сообщения идут на test_phone и считаются за него, как их
|
||||||
|
видит sms.ru. Номера сравниваются по последним 10 цифрам (8/7/+7 и т.п.).
|
||||||
|
|
||||||
|
Поведение при сбое БД — fail-open: недоступный счётчик не должен останавливать
|
||||||
|
доставку, в лог пишется ERROR. Повторные Telegram-алерты подавляются
|
||||||
|
(alert_due: не чаще одного раза в час, отметка в sms_campaign_settings).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("sms_rate_limit")
|
||||||
|
|
||||||
|
DEFAULT_PER_NUMBER = 8 # правило sms.ru: 8 сообщений на номер
|
||||||
|
DEFAULT_WINDOW_SECONDS = 600 # ... за 10 минут
|
||||||
|
DEFAULT_ACCOUNT = 0 # 0 = аккаунтный лимит не проверяем
|
||||||
|
ALERT_THROTTLE_SECONDS = 3600 # не чаще одного алерта в час
|
||||||
|
|
||||||
|
COUNT_SQL = """
|
||||||
|
SELECT COUNT(*) FROM sms_campaign_log
|
||||||
|
WHERE created_at > NOW() - (%(win)s || ' seconds')::interval
|
||||||
|
AND sms_id IS NOT NULL
|
||||||
|
{phone_filter}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PHONE_FILTER = ("AND right(regexp_replace("
|
||||||
|
"COALESCE(NULLIF(sent_to, ''), customer_phone), '\\D', '', 'g'), 10) "
|
||||||
|
"= right(regexp_replace(%(phone)s, '\\D', '', 'g'), 10)")
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value, default):
|
||||||
|
"""Аккуратно приводит настройку к int (None, '' и мусор → default)."""
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return int(default)
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
|
def rules(settings):
|
||||||
|
"""Правила лимита из настроек кампании: (на номер, всего, окно)."""
|
||||||
|
s = settings or {}
|
||||||
|
return (
|
||||||
|
_int(s.get("sms_limit_per_number_10min"), DEFAULT_PER_NUMBER),
|
||||||
|
_int(s.get("sms_limit_account_10min"), DEFAULT_ACCOUNT),
|
||||||
|
_int(s.get("sms_rate_window_seconds"), DEFAULT_WINDOW_SECONDS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _count(conn, window_seconds, phone=None):
|
||||||
|
sql = COUNT_SQL.format(phone_filter=PHONE_FILTER if phone else "")
|
||||||
|
params = {"win": window_seconds}
|
||||||
|
if phone:
|
||||||
|
params["phone"] = phone
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def check_send_allowed(conn, phone, settings):
|
||||||
|
"""Можно ли отправить ещё одну SMS на этот номер.
|
||||||
|
|
||||||
|
Возвращает (ok, reason, info):
|
||||||
|
ok — True (можно) / False (придержать)
|
||||||
|
reason — короткое объяснение для лога и Telegram
|
||||||
|
info — {'per_number': n, 'account': n, 'window': w} для алерта
|
||||||
|
"""
|
||||||
|
per_number, account, window = rules(settings)
|
||||||
|
info = {"per_number": per_number, "account": account, "window": window}
|
||||||
|
|
||||||
|
if per_number <= 0 and account <= 0:
|
||||||
|
return True, "лимит выключен в настройках", info
|
||||||
|
if not phone:
|
||||||
|
return True, "пустой номер", info
|
||||||
|
|
||||||
|
try:
|
||||||
|
if per_number > 0:
|
||||||
|
n = _count(conn, window, phone)
|
||||||
|
info["number_count"] = n
|
||||||
|
if n >= per_number:
|
||||||
|
return False, (f"номер {phone}: уже {n} SMS за {window}с "
|
||||||
|
f"(лимит {per_number})"), info
|
||||||
|
if account > 0:
|
||||||
|
total = _count(conn, window)
|
||||||
|
info["account_count"] = total
|
||||||
|
if total >= account:
|
||||||
|
return False, (f"аккаунт: уже {total} SMS за {window}с "
|
||||||
|
f"(лимит {account})"), info
|
||||||
|
except Exception as exc: # noqa: BLE001 — fail-open, доставку не стопим
|
||||||
|
log.error("sms rate limit check failed (fail-open): %s", exc)
|
||||||
|
info["db_error"] = str(exc)
|
||||||
|
return True, f"проверка лимита не удалась ({exc}) — отправляем", info
|
||||||
|
|
||||||
|
return True, "лимит не выбран", info
|
||||||
|
|
||||||
|
|
||||||
|
def alert_due(conn, settings, throttle_seconds=ALERT_THROTTLE_SECONDS):
|
||||||
|
"""Можно ли отправить Telegram-алерт про придержанные SMS (не чаще раза в час).
|
||||||
|
|
||||||
|
Отметка пишется в sms_campaign_settings.last_limit_alert_at, таблица
|
||||||
|
кампаний — единственная «своя» запись под рукой. Ошибки БД → False,
|
||||||
|
чтобы не заваливать чат алертами.
|
||||||
|
"""
|
||||||
|
campaign_type = (settings or {}).get("campaign_type")
|
||||||
|
if not campaign_type:
|
||||||
|
return True # настроек нет — не рискуем потерять уведомление
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE sms_campaign_settings
|
||||||
|
SET last_limit_alert_at = NOW()
|
||||||
|
WHERE campaign_type = %s
|
||||||
|
AND (last_limit_alert_at IS NULL
|
||||||
|
OR last_limit_alert_at < NOW() - (%s || ' seconds')::interval)
|
||||||
|
RETURNING id
|
||||||
|
""", (campaign_type, throttle_seconds))
|
||||||
|
ok = cur.fetchone() is not None
|
||||||
|
conn.commit()
|
||||||
|
return ok
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
conn.rollback()
|
||||||
|
log.error("alert_due failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
@ -35,6 +35,12 @@ import requests
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
try: # лимитер sms.ru (модуль лежит рядом со скриптом)
|
||||||
|
from sms_rate_limit import check_send_allowed, alert_due
|
||||||
|
except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше
|
||||||
|
check_send_allowed = None
|
||||||
|
alert_due = None
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
|
@ -63,6 +69,62 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
||||||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
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(
|
logging.basicConfig(
|
||||||
|
|
@ -83,6 +145,22 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
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):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
|
@ -104,7 +182,8 @@ def load_settings(conn):
|
||||||
"second_sms_delay_hours": 3,
|
"second_sms_delay_hours": 3,
|
||||||
"auto_manual_after_hours": 3,
|
"auto_manual_after_hours": 3,
|
||||||
}
|
}
|
||||||
return dict(row)
|
settings = dict(row)
|
||||||
|
return apply_schedule_work_days(conn, settings)
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -190,7 +269,7 @@ def send_telegram(message, chat_id):
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
def get_groups_to_send(conn, max_attempts):
|
||||||
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
||||||
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
||||||
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
|
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
|
||||||
|
|
@ -198,7 +277,8 @@ def get_groups_to_send(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
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
|
FROM order_groups og
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
|
@ -209,6 +289,15 @@ def get_groups_to_send(conn):
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
||||||
AND COALESCE(scp.second_sms_enabled, true) = true
|
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 в логе
|
-- Нет активной второй SMS в логе
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
@ -217,8 +306,15 @@ def get_groups_to_send(conn):
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND scl.status IN ('sent', 'checking')
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
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
|
ORDER BY og.created_at ASC
|
||||||
""")
|
""", (max_attempts,))
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
|
@ -301,6 +397,21 @@ def update_order_group(conn, group_id, fields):
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def count_sms_attempts(conn, group_id, campaign_type=CAMPAIGN_TYPE):
|
||||||
|
"""Сколько раз эту SMS уже пытались отправить (строк в логе кампании).
|
||||||
|
|
||||||
|
Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
|
||||||
|
новую строку, поэтому в строке всегда было бы attempts=1, условие
|
||||||
|
«attempts < max_attempts» выполнялось бы вечно и вторая SMS уходила бы
|
||||||
|
каждые 5 минут бесконечно.
|
||||||
|
"""
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = %s
|
||||||
|
""", (group_id, campaign_type))
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
def is_within_work_hours(settings):
|
||||||
"""Проверка: сейчас рабочие часы.
|
"""Проверка: сейчас рабочие часы.
|
||||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||||
|
|
@ -328,11 +439,15 @@ def step_send_new(conn, settings):
|
||||||
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_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")
|
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
limited_count = 0
|
||||||
|
limited_last = None
|
||||||
|
limited_info = None
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_id = str(group["id"])
|
group_id = str(group["id"])
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
|
@ -352,7 +467,8 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
|
log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
|
||||||
continue
|
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
|
send_phone = phone
|
||||||
|
|
@ -360,7 +476,19 @@ def step_send_new(conn, settings):
|
||||||
send_phone = settings.get("test_phone", "79788382260")
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
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})")
|
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)
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
if sms_id:
|
if sms_id:
|
||||||
|
|
@ -368,11 +496,13 @@ def step_send_new(conn, settings):
|
||||||
campaign_type=CAMPAIGN_TYPE,
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
order_group_id=group_id,
|
order_group_id=group_id,
|
||||||
customer_phone=phone,
|
customer_phone=phone,
|
||||||
|
sent_to=send_phone,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
sms_id=sms_id,
|
sms_id=sms_id,
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="sent",
|
status="sent",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
|
|
@ -390,7 +520,7 @@ def step_send_new(conn, settings):
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="send_failed",
|
status="send_failed",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
error_message=error,
|
error_message=error,
|
||||||
)
|
)
|
||||||
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
||||||
|
|
@ -399,6 +529,22 @@ def step_send_new(conn, settings):
|
||||||
})
|
})
|
||||||
send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||||||
|
|
||||||
|
if limited_count:
|
||||||
|
_win = (limited_info or {}).get("window")
|
||||||
|
_cap = (limited_info or {}).get("per_number")
|
||||||
|
_acct = (limited_info or {}).get("account")
|
||||||
|
msg = (
|
||||||
|
f"SMS придержаны лимитом sms.ru: {limited_count} шт. "
|
||||||
|
f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); "
|
||||||
|
f"причина: {limited_last}; "
|
||||||
|
f"сообщения не потеряны — уйдут в следующий запуск."
|
||||||
|
)
|
||||||
|
log.warning("Rate limit held %s SMS: %s", limited_count, limited_last)
|
||||||
|
if alert_due is not None and alert_due(conn, settings):
|
||||||
|
send_telegram(msg, tg_chat)
|
||||||
|
else:
|
||||||
|
log.info("Rate limit Telegram alert suppressed (throttled)")
|
||||||
|
|
||||||
return sent_count
|
return sent_count
|
||||||
|
|
||||||
def step_check_status(conn, settings):
|
def step_check_status(conn, settings):
|
||||||
|
|
@ -419,7 +565,7 @@ def step_check_status(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
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)
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
||||||
|
|
@ -501,7 +647,7 @@ def step_handle_expired(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
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}")
|
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",
|
update_sms_log(conn, log_id, status="expired",
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,19 @@
|
||||||
|
|
||||||
set -euo pipefail
|
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_PORT="5432"
|
||||||
DB_NAME="postgres"
|
DB_NAME="postgres"
|
||||||
DB_USER="supabase_admin"
|
DB_USER="supabase_admin"
|
||||||
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
if [ -z "$DB_HOST" ]; then
|
||||||
|
echo "$(date): ERROR: Cannot resolve supabase-db IP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
declare -A TIMERS=(
|
declare -A TIMERS=(
|
||||||
["first_sms"]="sms-first-campaign.timer"
|
["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"
|
||||||
Loading…
Reference in New Issue