fix(autocall): ручной звонок из карточки звонит клиенту (тестовый режим только для массового обзвона) + видимый процесс звонка в карточке
This commit is contained in:
parent
5a71fbceba
commit
8e9a0f6dab
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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("тестовый номер");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue