/** * @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; // Заявку взяли в работу, но 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}`, }; };