fix(autocall): ручной звонок из карточки звонит клиенту (тестовый режим только для массового обзвона) + видимый процесс звонка в карточке

This commit is contained in:
Hermes 2026-09-15 15:26:20 +02:00
parent 5a71fbceba
commit 8e9a0f6dab
4 changed files with 441 additions and 13 deletions

View File

@ -383,8 +383,13 @@ def resolve_call_type(group):
return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv" 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): def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
"""Формирует содержимое call-файла.""" """Формирует содержимое call-файла.
manual=True — звонок запущен руками из карточки заказа. Логист выбрал
конкретного клиента, поэтому звоним ему, даже если кампания в тестовом
режиме: тестовый режим страхует массовый обзвон, а не ручной запрос.
"""
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
phone = normalize_phone(phone) phone = normalize_phone(phone)
# Формат набора: 0 + номер без ведущей 7 # Формат набора: 0 + номер без ведущей 7
@ -395,6 +400,8 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
test_mode = bool(settings.get("test_mode", True)) test_mode = bool(settings.get("test_mode", True))
if is_test_call: if is_test_call:
test_mode = True test_mode = True
elif manual and group.get("id"):
test_mode = False
elif test_mode: elif test_mode:
test_phone = normalize_phone(settings.get("test_phone", "9067029953")) test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
@ -453,9 +460,9 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
] ]
return "\n".join(lines) return "\n".join(lines)
def push_call_file(group, settings, attempt_no, is_test_call=False): def push_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
"""Кладёт call-файл на АТС через SSH.""" """Кладёт call-файл на АТС через SSH."""
content = build_call_file(group, settings, attempt_no, is_test_call) content = build_call_file(group, settings, attempt_no, is_test_call, manual)
if content is None: if content is None:
# Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем. # Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем.
log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка") log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка")
@ -561,7 +568,7 @@ def process_queue(conn, settings):
# Для теста не подставляем customer_phone_normalized (нет заказа) # Для теста не подставляем customer_phone_normalized (нет заказа)
if is_test: if is_test:
g["customer_phone_normalized"] = None g["customer_phone_normalized"] = None
if push_call_file(g, settings, attempt_no, is_test_call=is_test): if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
mark_queue_processed(conn, q["queue_id"]) mark_queue_processed(conn, q["queue_id"])
pushed += 1 pushed += 1
attempt_map[group_id] = attempt_no + 1 attempt_map[group_id] = attempt_no + 1

View File

@ -3,10 +3,11 @@
* @description Блок «Автодозвон» в карточке заказа. * @description Блок «Автодозвон» в карточке заказа.
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить». * Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
*/ */
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews"; import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
import { describeCallProcess } from "../../services/autocallProcess";
const OUTCOME_META = { const OUTCOME_META = {
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" }, confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
@ -44,9 +45,11 @@ const slotText = (l) => {
export const AutocallStatusCard = ({ order, userRole }) => { export const AutocallStatusCard = ({ order, userRole }) => {
const [calls, setCalls] = useState([]); const [calls, setCalls] = useState([]);
const [queueItem, setQueueItem] = useState(null);
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState(false); const [toggling, setToggling] = useState(false);
const [queued, setQueued] = useState(false); const [sending, setSending] = useState(false);
const canManage = ["mega_admin", "logistician"].includes(userRole); const canManage = ["mega_admin", "logistician"].includes(userRole);
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false; const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
@ -72,11 +75,85 @@ export const AutocallStatusCard = ({ order, userRole }) => {
} }
}, [order?.id]); }, [order?.id]);
useEffect(() => { loadCalls(); }, [loadCalls]); // Последняя заявка «Позвонить сейчас» по этому заказу: без неё в карточке было
// не видно, ушёл ли запрос и что с ним стало.
const loadQueue = useCallback(async () => {
if (!order?.id) return;
try {
const { data } = await supabase
.from("autocall_queue")
.select("id, requested_at, processed_at, is_test_call, call_type, phone")
.eq("order_group_id", order.id)
.order("requested_at", { ascending: false })
.limit(1);
setQueueItem(data?.[0] || null);
} catch {
// silent
}
}, [order?.id]);
// Настройки кампании: рабочие часы/дни и тестовый режим — из них понятно,
// когда именно робот возьмёт заявку.
const loadSettings = useCallback(async () => {
try {
const { data } = await supabase
.from("sms_campaign_settings")
.select("enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days")
.eq("campaign_type", "autocall_slot")
.maybeSingle();
setSettings(data || null);
} catch {
// silent
}
}, []);
useEffect(() => {
loadCalls();
loadQueue();
loadSettings();
}, [loadCalls, loadQueue, loadSettings]);
// Звонок, который робот сделал по нашей заявке (в пределах 15 минут после неё).
const callAfter = useMemo(() => {
if (!queueItem?.requested_at) return null;
const from = new Date(queueItem.requested_at).getTime() - 1000;
const to = from + 15 * 60 * 1000;
return (
calls.find((c) => {
const at = new Date(c.created_at).getTime();
return at >= from && at <= to;
}) || null
);
}, [calls, queueItem]);
// Заявка свежая (сутки) — иначе в карточке висел бы вчерашний процесс.
const recentQueue = useMemo(() => {
if (!queueItem?.requested_at) return null;
return Date.now() - new Date(queueItem.requested_at).getTime() <= 24 * 60 * 60 * 1000 ? queueItem : null;
}, [queueItem]);
const process = useMemo(
() => describeCallProcess({ queueItem: recentQueue, callAfter, settings: settings || {} }),
[recentQueue, callAfter, settings],
);
const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing";
// Пока заявку не отработали (и в пределах 15 минут) — подтягиваем состояние каждые 7 секунд.
useEffect(() => {
if (!processing || !recentQueue) return undefined;
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
if (age > 15 * 60 * 1000) return undefined;
const timer = setInterval(() => {
loadQueue();
loadCalls();
}, 7000);
return () => clearInterval(timer);
}, [processing, recentQueue, loadQueue, loadCalls]);
const handleCallNow = async () => { const handleCallNow = async () => {
if (!order?.id) return; if (!order?.id) return;
setQueued(true); setSending(true);
try { try {
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn). // Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn).
// Сценарий планировщик определит сам по дню добавления группы // Сценарий планировщик определит сам по дню добавления группы
@ -91,10 +168,11 @@ export const AutocallStatusCard = ({ order, userRole }) => {
: {}), : {}),
}); });
if (error) throw error; if (error) throw error;
setTimeout(() => setQueued(false), 3000); await loadQueue();
} catch (e) { } catch (e) {
console.error("Queue call error:", e); console.error("Queue call error:", e);
setQueued(false); } finally {
setSending(false);
} }
}; };
@ -150,10 +228,45 @@ export const AutocallStatusCard = ({ order, userRole }) => {
</div> </div>
)} )}
{/* Состояние ручного звонка: заявка, набор, результат, «когда позвонят» */}
{process && (
<div
className={[
"mb-3 rounded-lg px-2.5 py-1.5 text-[11px]",
process.tone === "danger"
? "bg-[rgba(239,68,68,0.08)] text-[var(--color-danger)]"
: process.tone === "warning"
? "bg-[rgba(245,158,11,0.08)] text-[var(--color-warning)]"
: process.tone === "accent"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
].join(" ")}
>
<div className="flex items-center gap-1.5 font-medium">
{processing && (
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" aria-hidden="true" />
)}
<span>{process.title}</span>
</div>
<div className="mt-0.5 text-[10px] opacity-80">{process.hint}</div>
{process.phase === "done" && callAfter && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
{(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome}
</Badge>
<span className="text-[10px] opacity-80">{fmtTime(callAfter.created_at)}</span>
{slotText(callAfter) && <span className="text-[10px] opacity-80">{slotText(callAfter)}</span>}
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
</div>
)}
</div>
)}
{/* История звонков — как «История SMS» */} {/* История звонков — как «История SMS» */}
{calls.length > 0 ? ( {calls.length > 0 ? (
<div className="mt-1 border-t border-[var(--color-border)] pt-3"> <div className="mt-1 border-t border-[var(--color-border)] pt-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div> <div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div>
{loading && <div className="mb-1 text-[10px] text-[var(--color-text-muted)]">обновляем…</div>}
<div className="space-y-1.5"> <div className="space-y-1.5">
{calls.map((c, idx) => { {calls.map((c, idx) => {
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" }; const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
@ -199,10 +312,16 @@ export const AutocallStatusCard = ({ order, userRole }) => {
<button <button
type="button" type="button"
onClick={handleCallNow} onClick={handleCallNow}
disabled={queued || optOut} disabled={sending || processing || optOut}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50" className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
> >
{queued ? "✓ В очереди" : "📞 Позвонить сейчас"} {sending
? "Отправляем заявку…"
: process?.phase === "dialing"
? "📞 Звоним…"
: processing
? "✓ Заявка в очереди"
: "📞 Позвонить сейчас"}
</button> </button>
<button <button
type="button" type="button"

View File

@ -0,0 +1,162 @@
/**
* @file autocallProcess.js
* @description Что происходит с ручным звонком из карточки заказа.
*
* Логист нажимает «Позвонить сейчас»: заявка уходит в очередь, робот берёт её
* на очередном тике (раз в минуту, только в рабочее время) и звонит. Раньше
* в интерфейсе не было видно ни очереди, ни результата — кнопка «отжималась»
* и всё. Здесь собраны состояния процесса в человеческие фразы.
*
* Время считаем в Москве (браузер может быть в другом поясе), часы и дни
* берём из настроек кампании автодозвона.
*/
const WEEKDAY_LABEL = {
1: "в понедельник",
2: "во вторник",
3: "в среду",
4: "в четверг",
5: "в пятницу",
6: "в субботу",
7: "в воскресенье",
};
/** Строка настроек «1,2,3,4,5,6» → [1,2,3,4,5,6]. */
export const parseWorkDays = (raw, fallback = [1, 2, 3, 4, 5, 6]) => {
if (Array.isArray(raw)) {
const days = raw.map((d) => Number(d)).filter((d) => d >= 1 && d <= 7);
return days.length ? days : fallback;
}
const text = String(raw ?? "");
const days = text.split(/[^0-9]+/).filter(Boolean).map(Number).filter((d) => d >= 1 && d <= 7);
return days.length ? days : fallback;
};
/** Текущее время в Москве: минуты от полуночи и день недели (1=пн). */
export const moscowNow = (now = new Date()) => {
const parts = new Intl.DateTimeFormat("ru-RU", {
timeZone: "Europe/Moscow",
hour: "2-digit",
minute: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(now);
const get = (type) => (parts.find((p) => p.type === type) || {}).value || "";
const weekdays = { пн: 1, вт: 2, ср: 3, чт: 4, пт: 5, сб: 6, вс: 7 };
const hour = Number(get("hour")) % 24;
const minute = Number(get("minute"));
return {
minutes: hour * 60 + minute,
hour,
minute,
weekday: weekdays[get("weekday")] || 0,
};
};
const padHour = (h) => `${String(h).padStart(2, "0")}:00`;
/** Сейчас робот звонит? (рабочий день и рабочие часы кампании) */
export const isCallTimeNow = (settings = {}, now = new Date()) => {
const workDays = parseWorkDays(settings.work_days);
const start = Number(settings.work_hours_start ?? 9) * 60;
const end = Number(settings.work_hours_end ?? 20) * 60;
const t = moscowNow(now);
return workDays.includes(t.weekday) && t.minutes >= start && t.minutes < end;
};
/** Когда откроется ближайшее окно звонков: «сегодня», «завтра», «в понедельник». */
export const nextCallDayLabel = (settings = {}, now = new Date()) => {
const workDays = parseWorkDays(settings.work_days);
const start = Number(settings.work_hours_start ?? 9) * 60;
const t = moscowNow(now);
for (let shift = 0; shift <= 7; shift += 1) {
const weekday = ((t.weekday - 1 + shift) % 7) + 1;
if (!workDays.includes(weekday)) continue;
// сегодня окно уже открыто — значит «сейчас», сюда не попадём
if (shift === 0 && t.minutes >= start) continue;
if (shift === 0) return "сегодня";
if (shift === 1) return "завтра";
return WEEKDAY_LABEL[weekday] || "скоро";
}
return "скоро";
};
const fmtTime = (ts) => {
if (!ts) return "—";
try {
// Время показываем по Москве: логисты и админы работают в этом поясе,
// а браузер может быть в другом (частая путаница «звонил в 9 или в 11?»).
return new Date(ts).toLocaleString("ru-RU", {
timeZone: "Europe/Moscow", hour: "2-digit", minute: "2-digit",
});
} catch {
return "—";
}
};
/**
* Состояние ручного звонка в человеческих словах.
*
* @param {{queueItem?: object|null, callAfter?: object|null, settings?: object, now?: Date}} input
* queueItem — заявка из autocall_queue (последняя по заказу)
* callAfter — запись autocall_log, появившаяся после заявки (значит, звонок уже состоялся)
* @returns {null|{phase: string, title: string, hint: string, tone: string}} null — заявок не было
*/
export const describeCallProcess = ({ queueItem, callAfter, settings = {}, now = new Date() } = {}) => {
if (!queueItem) return null;
const workDays = parseWorkDays(settings.work_days);
const startHour = Number(settings.work_hours_start ?? 9);
const endHour = Number(settings.work_hours_end ?? 20);
const requested = `заявка от ${fmtTime(queueItem.requested_at)}`;
if (callAfter) {
return {
phase: "done",
tone: callAfter.was_test_mode ? "warning" : "neutral",
title: callAfter.was_test_mode ? "Звонок сделан в тестовом режиме" : "Звонок сделан",
hint: callAfter.was_test_mode
? `Дозвон шёл на тестовый номер (режим кампании), не клиенту — ${requested}`
: `Результат ниже в истории звонков — ${requested}`,
};
}
if (queueItem.processed_at) {
return {
phase: "dialing",
tone: "accent",
title: "Звоним клиенту…",
hint: `Робот уже набирает номер, результат появится через несколько секунд — ${requested}`,
};
}
const soon = isCallTimeNow({ work_hours_start: startHour, work_hours_end: endHour, work_days: workDays }, now);
const ageMinutes = (now.getTime() - new Date(queueItem.requested_at).getTime()) / 60000;
if (soon) {
// В рабочее время заявку обязаны взять на ближайшем тике (раз в минуту).
if (ageMinutes > 15) {
return {
phase: "stuck",
tone: "danger",
title: "Заявку не забрали больше 15 минут",
hint: `Рабочее время идёт, а звонка нет — похоже, планировщик звонков не работает. ${requested}`,
};
}
return {
phase: "pending",
tone: "accent",
title: "В очереди: звонок начнётся в течение минуты",
hint: `Робот забирает заявки раз в минуту (${padHour(startHour)}–${padHour(endHour)}) — ${requested}`,
};
}
return {
phase: "pending_later",
tone: "warning",
title: `Сейчас нерабочее время — позвоним ${nextCallDayLabel(
{ work_hours_start: startHour, work_days: workDays }, now,
)} с ${padHour(startHour)}`,
hint: `Робот звонит только в рабочее время: ${padHour(startHour)}–${padHour(endHour)}, дни — ${workDays.join(", ")} — ${requested}`,
};
};

View File

@ -0,0 +1,140 @@
/**
* @file autocallProcess.test.js
* @description Ручной звонок из карточки: состояния должны быть понятны логисту —
* в очереди, идёт звонок, звонок сделан, и когда именно позвонят вне рабочего времени.
*/
import { describe, it, expect } from "vitest";
import {
describeCallProcess,
isCallTimeNow,
moscowNow,
nextCallDayLabel,
parseWorkDays,
} from "./autocallProcess";
/** Время в Москве → Date (браузер может быть в любом поясе, проверяем через МСК). */
const msk = (iso) => new Date(`${iso}+03:00`);
const SETTINGS = { work_hours_start: 9, work_hours_end: 20, work_days: "1,2,3,4,5,6" };
describe("parseWorkDays", () => {
it("читает строку из настроек", () => {
expect(parseWorkDays("1,2,3,4,5")).toEqual([1, 2, 3, 4, 5]);
expect(parseWorkDays([1, 6])).toEqual([1, 6]);
});
it("пусто или мусор → пн–сб по умолчанию", () => {
expect(parseWorkDays("")).toEqual([1, 2, 3, 4, 5, 6]);
expect(parseWorkDays("0,9")).toEqual([1, 2, 3, 4, 5, 6]);
});
});
describe("время Москвы и рабочее окно", () => {
it("считает время в Москве, а не в поясе браузера", () => {
expect(moscowNow(new Date("2026-09-15T07:30:00Z")).minutes).toBe(10 * 60 + 30);
expect(moscowNow(new Date("2026-09-15T07:30:00Z")).weekday).toBe(2); // вторник
});
it("в рабочее время звонить можно, в 21:00 и в воскресенье — нет", () => {
expect(isCallTimeNow(SETTINGS, msk("2026-09-15T12:00:00"))).toBe(true);
expect(isCallTimeNow(SETTINGS, msk("2026-09-15T21:00:00"))).toBe(false);
expect(isCallTimeNow(SETTINGS, msk("2026-09-20T12:00:00"))).toBe(false); // воскресенье
});
});
describe("когда откроется ближайшее окно", () => {
it("вечером — завтра", () => {
expect(nextCallDayLabel(SETTINGS, msk("2026-09-15T21:30:00"))).toBe("завтра");
});
it("в воскресенье — завтра (понедельник)", () => {
expect(nextCallDayLabel(SETTINGS, msk("2026-09-20T12:00:00"))).toBe("завтра");
});
it("в пятницу вечером, если суббота нерабочая — в понедельник", () => {
const weekdaysOnly = { ...SETTINGS, work_days: "1,2,3,4,5" };
expect(nextCallDayLabel(weekdaysOnly, msk("2026-09-18T21:00:00"))).toBe("в понедельник");
});
});
describe("describeCallProcess — что видит логист", () => {
const queueItem = { id: 42, requested_at: "2026-09-15T07:34:00Z", processed_at: null };
it("заявок не было — блока нет", () => {
expect(describeCallProcess({ queueItem: null })).toBeNull();
});
it("в рабочее время: говорит, что звонок будет в течение минуты", () => {
const res = describeCallProcess({
queueItem: { id: 42, requested_at: "2026-09-15T08:57:00Z", processed_at: null }, // 11:57 МСК
settings: SETTINGS,
now: msk("2026-09-15T12:00:00"),
});
expect(res.phase).toBe("pending");
expect(res.title).toContain("в течение минуты");
expect(res.hint).toContain("заявка от 11:57");
});
it("вне рабочего времени: честно говорит, когда позвонят", () => {
const res = describeCallProcess({
queueItem,
settings: SETTINGS,
now: msk("2026-09-15T22:00:00"),
});
expect(res.phase).toBe("pending_later");
expect(res.title).toContain("позвоним завтра с 09:00");
});
it("заявка взята роботом — «звоним клиенту»", () => {
const res = describeCallProcess({
queueItem: { ...queueItem, processed_at: "2026-09-15T07:35:10Z" },
settings: SETTINGS,
now: msk("2026-09-15T10:35:20"),
});
expect(res.phase).toBe("dialing");
expect(res.title).toContain("Звоним клиенту");
});
it("в рабочее время заявку держат больше 15 минут — тревога, а не «в очереди»", () => {
const res = describeCallProcess({
queueItem: { id: 43, requested_at: "2026-09-15T07:00:00Z", processed_at: null },
settings: SETTINGS,
now: msk("2026-09-15T12:00:00"),
});
expect(res.phase).toBe("stuck");
expect(res.tone).toBe("danger");
expect(res.hint).toContain("планировщик звонков не работает");
});
it("та же старая заявка ночью — это не тревога, ждём рабочее время", () => {
const res = describeCallProcess({
queueItem: { id: 43, requested_at: "2026-09-15T07:00:00Z", processed_at: null },
settings: SETTINGS,
now: msk("2026-09-15T23:00:00"),
});
expect(res.phase).toBe("pending_later");
});
it("звонок состоялся — отправляет к результату в истории", () => {
const res = describeCallProcess({
queueItem: { ...queueItem, processed_at: "2026-09-15T07:35:10Z" },
callAfter: { id: 7, outcome: "no_answer", created_at: "2026-09-15T07:36:00Z", was_test_mode: false },
settings: SETTINGS,
now: msk("2026-09-15T10:40:00"),
});
expect(res.phase).toBe("done");
expect(res.title).toBe("Звонок сделан");
expect(res.hint).not.toContain("тестовый номер");
});
it("тестовый режим: предупреждает, что звонили не клиенту", () => {
const res = describeCallProcess({
queueItem,
callAfter: { id: 8, outcome: "no_answer", was_test_mode: true },
settings: SETTINGS,
now: msk("2026-09-15T10:40:00"),
});
expect(res.tone).toBe("warning");
expect(res.hint).toContain("тестовый номер");
});
});