188 lines
8.9 KiB
JavaScript
188 lines
8.9 KiB
JavaScript
/**
|
||
* @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}`,
|
||
};
|
||
};
|