diff --git a/public/service-worker.js b/public/service-worker.js index 8d5f0f4..2e38ce7 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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"]; diff --git a/scripts/check_autocall_window.py b/scripts/check_autocall_window.py index 0357385..5288b84 100644 --- a/scripts/check_autocall_window.py +++ b/scripts/check_autocall_window.py @@ -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): +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(); manual.clear() - def _fake_push(group, settings, attempt_no, is_test_call=False, **kw): - pushes.append(group["customer_phone"]) - return True - + 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) diff --git a/src/components/admin/autocall/AutocallChannelTab.jsx b/src/components/admin/autocall/AutocallChannelTab.jsx index 9b26481..37bcc2e 100644 --- a/src/components/admin/autocall/AutocallChannelTab.jsx +++ b/src/components/admin/autocall/AutocallChannelTab.jsx @@ -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 = () => { по Москве +
+ {WEEK_DAYS.map((d) => { + const isOn = parseWorkDays(settings.work_days, []).includes(d.num); + return ( + + ); + })} +
- Звонки — только в выбранные часы и дни. Попытка за окном переносится на 09:00 следующего рабочего дня. + Звонки — только в выбранные часы и дни. Вне окна робот не звонит вообще: попытка, + выпавшая на нерабочее время или выходной, уходит на ближайшее рабочее утро. + Изменения применяются по кнопке «Сохранить».