supersam/scripts/autocall_scheduler.py

791 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"
# 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 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: PJSIP/{dial}@mmedia",
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
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
# Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) —
# роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено.
# Заявку НЕ закрываем (правило владельца) — просто не обрабатываем.
agreed = (str(q.get("notification_status") or "") in ("confirmed", "manual_required", "opt_out")
or q.get("delivery_date") is not None)
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 push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
mark_queue_processed(conn, q["queue_id"])
pushed += 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")
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 push_call_file(group, settings, attempt_no):
pushed += 1
time.sleep(1) # пауза между звонками
log.info(f"Pushed {pushed} call files")
finally:
conn.close()
if __name__ == "__main__":
main()