feat(admin): дни недели в окне звонков автодозвона
Владелец: «в субботу звонки не должны происходить, всё это настраивается из админки». В панели «Автодозвон» в блоке «Окно звонков» были только часы, а дни (work_days) править из админки было нечем — их приходилось менять в БД, хотя планировщик именно по ним и решает, звонить ли сегодня. Добавлены таблетки дней Пн–Вс тем же механизмом и в том же виде, что в панели SMS-кампаний: пустой набор не подменяется дефолтом (снял все дни — значит звонков нет, и в панели это видно). Подсказка в панели теперь честно описывает поведение: вне окна робот не звонит вообще, попытка уходит на ближайшее рабочее утро. Харнесс check_autocall_window.py считает ожидания ИЗ НАСТРОЕК (часы + дни), поэтому проверка не устаревает при смене графика. SW: v120 -> v121.
This commit is contained in:
parent
d1799ca738
commit
6944386b3b
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue