893 lines
46 KiB
Python
893 lines
46 KiB
Python
#!/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')
|
||
-- Пауза после 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 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)
|
||
|
||
# Самовывоз: конкретные даты передаём в диалог явно — робот называет их
|
||
# голосом и возвращает выбранную дату, а не абстрактный «день 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"),
|
||
)
|
||
|
||
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
|
||
|
||
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()
|