supersam/src/services/autocallProcess.js

188 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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}`,
};
}
// Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам):
// звонок не нужен и планировщик заявку не обрабатывает — говорим честно.
// Заявку при этом НЕ закрываем и НЕ удаляем (правило владельца).
if (settings.order_agreed) {
return {
phase: "pending",
tone: "neutral",
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;
// Заявку взяли в работу, но call-файл не создан: по графику окна звонка для
// этого заказа уже нет (например, окно самовывоза закрылось — заказ готов
// раньше, чем «сегодня + 2 рабочих дня»). Планировщик молча пропускает её
// каждый тик — покажем это честно, а не «в очереди».
if (settings.call_unreachable) {
return {
phase: "pending",
tone: "warning",
title: "Заявка в очереди — но роботу пока нечего предложить",
hint: `По графику у заказа нет доступных дней: заявка ждёт открытия окна звонка — ${requested}. Дальше вручную: выберите день в карточке или позвоните сами.`,
};
}
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}`,
};
};