feat(admin): дни недели в окне звонков автодозвона

Владелец: «в субботу звонки не должны происходить, всё это настраивается из
админки». В панели «Автодозвон» в блоке «Окно звонков» были только часы, а дни
(work_days) править из админки было нечем — их приходилось менять в БД, хотя
планировщик именно по ним и решает, звонить ли сегодня.

Добавлены таблетки дней Пн–Вс тем же механизмом и в том же виде, что в панели
SMS-кампаний: пустой набор не подменяется дефолтом (снял все дни — значит
звонков нет, и в панели это видно). Подсказка в панели теперь честно описывает
поведение: вне окна робот не звонит вообще, попытка уходит на ближайшее
рабочее утро.

Харнесс check_autocall_window.py считает ожидания ИЗ НАСТРОЕК (часы + дни),
поэтому проверка не устаревает при смене графика.

SW: v120 -> v121.
This commit is contained in:
Hermes 2026-09-28 22:53:34 +02:00
parent d1799ca738
commit 6944386b3b
3 changed files with 96 additions and 35 deletions

View File

@ -1,7 +1,7 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v120";
const STATIC_CACHE = "construction-delivery-static-v121";
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];

View File

@ -2,17 +2,20 @@
"""Детерминированная проверка окна обзвона автодозвона.
Проверяет, что автоматическая кампания НЕ ставит call-файлы вне рабочего окна
(часы и дни из панели «Автодозвон»), не звонит в выходные и звонит внутри окна.
Ничего не звонит: push_call_file, process_queue, backfill и выборка кандидатов
подменены, время — фейковое (модульная datetime). Запускать на .8 через обёртку
(она подставит DB_*):
(часы и дни берутся из настроек кампании `autocall_slot`), не звонит в выходные
и звонит внутри окна. Ничего не звонит: push_call_file, process_queue, backfill
и выборка кандидатов подменены, время — фейковое (модульная datetime).
bash /opt/supersam/scripts/sms_run_with_db.sh \
Запускать на .8 через обёртку (она подставит DB_*):
bash /opt/supersam/scripts/sms_run_with_db.sh \\
/opt/supersam/scripts/check_autocall_window.py [путь_к_проверяемому_файлу]
Без аргумента проверяется установленный /opt/supersam/scripts/autocall_scheduler.py
(можно проверять и файл-кандидат до установки). Зачем: инцидент 28.09.2026 —
окно проверялось только для ручной очереди, и робот позвонил клиенту в 21:03.
(можно проверять и файл-кандидат до установки). Ожидания считаются ИЗ НАСТРОЕК
(work_hours_start/end, work_days), поэтому проверка не устаревает при смене графика.
Зачем: инцидент 28.09.2026 — окно проверялось только для ручной очереди, и робот
позвонил клиенту в 21:03 МСК.
"""
import importlib.util, datetime as _dt, sys
from importlib.machinery import SourceFileLoader
@ -38,34 +41,43 @@ class FakeDT(_dt.datetime):
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, 9, 0, tzinfo=_dt.timezone.utc),
"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, 8, 0, tzinfo=_dt.timezone.utc),
"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 = []
manual = []
def run_case(label, when_msk, expect_push, settings_patch=None):
FakeDT.fixed = when_msk.astimezone(MSK)
pushes.clear(); manual.clear()
def _fake_push(group, settings, attempt_no, is_test_call=False, **kw):
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: (manual.append(1), 0)[1]
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
@ -73,28 +85,33 @@ def run_case(label, when_msk, expect_push, settings_patch=None):
m.trunk_name = lambda settings: "goip5"
m.get_city_exclusion = lambda conn, town: None
m.get_exclusion_match = lambda conn, phone: None
if settings_patch:
orig = m.load_settings
def patched(conn):
s = orig(conn)
s.update(settings_patch)
return s
m.load_settings = patched
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 = []
results.append(run_case("понедельник 21:03 МСК (вне окна, инцидент)", _dt.datetime(2026, 9, 28, 21, 3, tzinfo=MSK), False))
results.append(run_case("понедельник 19:58 МСК (вне окна, инцидент)", _dt.datetime(2026, 9, 28, 19, 58, tzinfo=MSK), False))
results.append(run_case("понедельник 10:00 МСК (в окне)", _dt.datetime(2026, 9, 28, 10, 0, tzinfo=MSK), True))
results.append(run_case("понедельник 18:59 МСК (последняя минута окна)", _dt.datetime(2026, 9, 28, 18, 59, tzinfo=MSK), True))
results.append(run_case("воскресенье 12:00 МСК (нерабочий день)", _dt.datetime(2026, 9, 27, 12, 0, tzinfo=MSK), False))
results.append(run_case("суббота 12:00 МСК (рабочий день)", _dt.datetime(2026, 9, 26, 12, 0, tzinfo=MSK), True))
results.append(run_case("понедельник 09:00 МСК (граница окна)", _dt.datetime(2026, 9, 28, 9, 0, tzinfo=MSK), True))
# Каждый день недели в середине окна: звонок только если день рабочий.
for num in range(1, 8):
day = MONDAY + _dt.timedelta(days=num - 1)
when = day.replace(hour=min(START + 1, 23))
results.append(run_case(f"{DAY_NAMES[num]} {when:%d.%m %H:%M} МСК (середина окна)", when, num in WORK_DAYS))
# Границы окна — на первом рабочем дне недели.
work_day = MONDAY + _dt.timedelta(days=WORK_DAYS[0] - 1) if WORK_DAYS else MONDAY
if WORK_DAYS:
results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {START - 1 if START else 0:02d}:00 МСК (час до окна)", work_day.replace(hour=max(START - 1, 0)), False))
results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {START:02d}:00 МСК (начало окна)", work_day.replace(hour=START), True))
results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {END - 1:02d}:59 МСК (конец окна)", work_day.replace(hour=END - 1), True))
results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {END:02d}:00 МСК (час после окна)", work_day.replace(hour=END), False))
# Инцидент 28.09.2026: поздние звонки — звонков быть не должно.
results.append(run_case("пн 19:58 МСК (инцидент)", _dt.datetime(2026, 9, 28, 19, 58, tzinfo=MSK), False))
results.append(run_case("пн 21:03 МСК (инцидент)", _dt.datetime(2026, 9, 28, 21, 3, tzinfo=MSK), False))
print("\nИТОГ:", "ВСЕ PASS" if all(results) else "ЕСТЬ FAIL")
sys.exit(0 if all(results) else 1)

View File

@ -13,8 +13,21 @@ import { TestCallPanel } from "./TestCallPanel";
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
import { autocallSlotText } from "../../orders/autocallSlot";
import { parseWorkDays } from "../../../services/autocallProcess";
// ── Кампании звонков ─────────────────────────────────────────────────────────
// Дни недели для окна звонков — те же подписи, что в панели SMS-кампаний.
const WEEK_DAYS = [
{ num: 1, short: "Пн" },
{ num: 2, short: "Вт" },
{ num: 3, short: "Ср" },
{ num: 4, short: "Чт" },
{ num: 5, short: "Пт" },
{ num: 6, short: "Сб" },
{ num: 7, short: "Вс" },
];
const serializeWorkDays = (days) => [...new Set(days)].sort((a, b) => a - b).join(",");
const CAMPAIGNS = [
{ key: "autocall_slot", label: "Согласование слота", icon: "🔊", live: true },
{ key: "autocall_storage", label: "Платное хранение (голосом)", icon: "📦", live: false },
@ -138,6 +151,16 @@ export const AutocallChannelTab = () => {
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
};
// Дни, в которые робот звонит. Пустой набор не подменяем дефолтом: если владелец
// снял все дни — значит звонков быть не должно, и в панели это должно быть видно.
const toggleWorkDay = (dayNum) => {
const current = parseWorkDays(settings?.work_days, []);
const next = current.includes(dayNum)
? current.filter((d) => d !== dayNum)
: [...current, dayNum];
updateSetting("work_days", serializeWorkDays(next));
};
const handleSave = async () => {
if (!settings) return;
setSaving(true);
@ -335,8 +358,29 @@ export const AutocallChannelTab = () => {
</select>
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{WEEK_DAYS.map((d) => {
const isOn = parseWorkDays(settings.work_days, []).includes(d.num);
return (
<button
key={d.num}
type="button"
onClick={() => toggleWorkDay(d.num)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
isOn
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{d.short}
</button>
);
})}
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Звонки — только в выбранные часы и дни. Попытка за окном переносится на 09:00 следующего рабочего дня.
Звонки — только в выбранные часы и дни. Вне окна робот не звонит вообще: попытка,
выпавшая на нерабочее время или выходной, уходит на ближайшее рабочее утро.
Изменения применяются по кнопке «Сохранить».
</div>
</div>