fix(autocall): карточка показывает реальный день слота, а не «завтра»
Цифра дня у робота (dtmf_day) — это не «завтра», а первый рабочий день окна: звонок в пятницу предлагает понедельник, а суббота выходная. Карточка писала «завтра, вторая половина дня» при согласованном 28.09 — менеджер видел день, которого в окне нет. Слот собирает общая функция components/orders/autocallSlot.js: дата из строки лога (chosen_date), для старых строк — дата заказа, если слот согласовал этот звонок. Если даты нет — показываем только половину дня, ничего не выдумывая. Подключено в карточке заказа, вкладке «Автодозвон» и панели тест-звонка. Есть юнит-тесты (5 кейсов).
This commit is contained in:
parent
2067ff7d89
commit
004050c0ff
|
|
@ -12,6 +12,7 @@ import { useAuth } from "../../../context/AuthContext";
|
|||
import { TestCallPanel } from "./TestCallPanel";
|
||||
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
|
||||
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
|
||||
import { autocallSlotText } from "../../orders/autocallSlot";
|
||||
|
||||
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
||||
const CAMPAIGNS = [
|
||||
|
|
@ -33,9 +34,6 @@ const OUTCOME_META = {
|
|||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||
};
|
||||
|
||||
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
||||
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
||||
|
||||
const fmtTime = (ts) => {
|
||||
if (!ts) return "—";
|
||||
try {
|
||||
|
|
@ -55,14 +53,9 @@ const fmtPhone = (phone) => {
|
|||
return phone;
|
||||
};
|
||||
|
||||
const slotText = (l) => {
|
||||
if (!l) return null;
|
||||
const day = DAY_LABEL[l.dtmf_day];
|
||||
const half = HALF_LABEL[l.dtmf_half];
|
||||
if (day && half) return `${day}, ${half}`;
|
||||
if (day) return day;
|
||||
return null;
|
||||
};
|
||||
// Слот — общей функцией из карточки заказа: она показывает реальную дату,
|
||||
// а не «завтра» (цифра дня — это первый рабочий день окна, а не завтрашний день).
|
||||
const slotText = (l) => autocallSlotText(l, null);
|
||||
|
||||
const SettingField = ({ label, value, onChange, hint }) => (
|
||||
<label className="block" title={hint || undefined}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react";
|
|||
import { supabase } from "../../../supabaseClient";
|
||||
import { Panel } from "../../UI/Panel";
|
||||
import { Badge } from "../../UI/Badge";
|
||||
import { autocallSlotText } from "../../orders/autocallSlot";
|
||||
|
||||
const OUTCOME_META = {
|
||||
in_progress: { label: "Идёт звонок…", tone: "neutral", icon: "🔔" },
|
||||
|
|
@ -24,9 +25,6 @@ const OUTCOME_META = {
|
|||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||
};
|
||||
|
||||
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
||||
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
||||
|
||||
const fmtPhone = (phone) => {
|
||||
if (!phone) return "—";
|
||||
const d = String(phone).replace(/\D/g, "");
|
||||
|
|
@ -138,9 +136,7 @@ export const TestCallPanel = () => {
|
|||
useEffect(() => () => stopPolling(), [stopPolling]);
|
||||
|
||||
const m = OUTCOME_META[result?.outcome] || OUTCOME_META.in_progress;
|
||||
const slotText = result?.dtmf_day || result?.dtmf_half
|
||||
? `${DAY_LABEL[result.dtmf_day] || ""}${result.dtmf_day && result.dtmf_half ? ", " : ""}${HALF_LABEL[result.dtmf_half] || ""}`.trim() || "—"
|
||||
: null;
|
||||
const slotText = autocallSlotText(result, null) || null;
|
||||
|
||||
return (
|
||||
<Panel className="p-4">
|
||||
|
|
@ -244,9 +240,7 @@ export const TestCallPanel = () => {
|
|||
<div className="space-y-1">
|
||||
{lastTests.map((t) => {
|
||||
const tm = OUTCOME_META[t.outcome] || OUTCOME_META.error;
|
||||
const slot = t.dtmf_day || t.dtmf_half
|
||||
? `${DAY_LABEL[t.dtmf_day] || ""}${t.dtmf_day && t.dtmf_half ? ", " : ""}${HALF_LABEL[t.dtmf_half] || ""}`.trim() || "—"
|
||||
: null;
|
||||
const slot = autocallSlotText(t, null) || null;
|
||||
return (
|
||||
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
|
||||
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { autocallSlotText } from "./autocallSlot";
|
||||
|
||||
const order = { pickup_date: "2026-09-28", delivery_date: "2026-09-28" };
|
||||
|
||||
describe("autocallSlotText — слот автодозвона в карточке", () => {
|
||||
it("показывает дату, а не слово «завтра» (день 1 в пятницу = понедельник)", () => {
|
||||
const row = { outcome: "confirmed", dtmf_day: 1, dtmf_half: 2 };
|
||||
expect(autocallSlotText(row, order)).toBe("28.09, вторая половина дня");
|
||||
expect(autocallSlotText(row, order)).not.toContain("завтра");
|
||||
});
|
||||
|
||||
it("берёт дату из строки лога, если она там есть", () => {
|
||||
const row = { outcome: "confirmed", dtmf_day: 2, dtmf_half: 1, chosen_date: "2026-09-29" };
|
||||
expect(autocallSlotText(row, order)).toBe("29.09, первая половина дня");
|
||||
});
|
||||
|
||||
it("не выдумывает день у старой строки без даты — остаётся только половина дня", () => {
|
||||
expect(autocallSlotText({ outcome: "no_input_half", dtmf_day: 1, dtmf_half: 2 }, {}))
|
||||
.toBe("вторая половина дня");
|
||||
});
|
||||
|
||||
it("возвращает null, если в звонке не выбрано ничего", () => {
|
||||
expect(autocallSlotText({ outcome: "no_answer" }, order)).toBeNull();
|
||||
expect(autocallSlotText(null, order)).toBeNull();
|
||||
});
|
||||
|
||||
it("понимает camelCase-поля", () => {
|
||||
expect(autocallSlotText(
|
||||
{ outcome: "confirmed", dtmf_day: 2, dtmf_half: 1 },
|
||||
{ pickupDate: "2026-09-29" },
|
||||
)).toBe("29.09, первая половина дня");
|
||||
expect(autocallSlotText(
|
||||
{ outcome: "confirmed", dtmfDay: 1, dtmfHalf: 1, chosenDate: "2026-09-30" },
|
||||
order,
|
||||
)).toBe("30.09, первая половина дня");
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { supabase } from "../../supabaseClient";
|
|||
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
||||
import { describeCallProcess, isOrderAgreed } from "../../services/autocallProcess";
|
||||
import { isAutocallPossible } from "../../services/autocallAvailability";
|
||||
import { autocallSlotText } from "./autocallSlot";
|
||||
|
||||
const OUTCOME_META = {
|
||||
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
||||
|
|
@ -21,9 +22,6 @@ const OUTCOME_META = {
|
|||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||
};
|
||||
|
||||
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
||||
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
||||
|
||||
const fmtTime = (ts) => {
|
||||
if (!ts) return "—";
|
||||
try {
|
||||
|
|
@ -33,15 +31,6 @@ const fmtTime = (ts) => {
|
|||
} catch { return ts; }
|
||||
};
|
||||
|
||||
const slotText = (l) => {
|
||||
if (!l) return null;
|
||||
const day = DAY_LABEL[l.dtmf_day];
|
||||
const half = HALF_LABEL[l.dtmf_half];
|
||||
if (day && half) return `${day}, ${half}`;
|
||||
if (day) return day;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const AutocallStatusCard = ({ order, userRole }) => {
|
||||
const [calls, setCalls] = useState([]);
|
||||
const [queueItem, setQueueItem] = useState(null);
|
||||
|
|
@ -238,7 +227,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
|
||||
const lastCall = calls[0];
|
||||
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
|
||||
const lastSlot = lastCall ? slotText(lastCall) : null;
|
||||
const lastSlot = lastCall ? autocallSlotText(lastCall, order) : null;
|
||||
|
||||
// Единый человеческий статус (по аналогии со статусом SMS)
|
||||
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
|
||||
|
|
@ -297,7 +286,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
{(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>}
|
||||
{autocallSlotText(callAfter, order) && <span className="text-[10px] opacity-80">{autocallSlotText(callAfter, order)}</span>}
|
||||
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -312,7 +301,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
<div className="space-y-1.5">
|
||||
{calls.map((c, idx) => {
|
||||
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
||||
const slot = slotText(c);
|
||||
const slot = autocallSlotText(c, order);
|
||||
const attemptNo = calls.length - idx;
|
||||
return (
|
||||
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* @file autocallSlot.js
|
||||
* @description Слот, который согласовал робот автодозвона, — по-человечески.
|
||||
*
|
||||
* Вынесено из AutocallStatusCard.jsx отдельным модулем: логика чистая
|
||||
* (ни React, ни supabase), поэтому её видно и можно покрыть юнит-тестами.
|
||||
*
|
||||
* ВАЖНО про цифру дня (dtmf_day): это НЕ «завтра». Робот перечисляет конкретные
|
||||
* дни окна (у самовывоза — рабочие дни склада), поэтому «день 1» в пятницу может
|
||||
* быть понедельником: 25.09 (пт) → день 1 = 28.09 (пн), а 26.09 — суббота, когда
|
||||
* компания не работает. Слово «завтра» в карточке вводило менеджера в заблуждение.
|
||||
*/
|
||||
|
||||
export const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
||||
|
||||
// Итоги, при которых слот считается согласованным (тогда дату можно взять у заказа).
|
||||
export const CONFIRMED_OUTCOMES = ["confirmed", "confirmed_kept_previous", "confirmed_silent"];
|
||||
|
||||
/** Дата в виде ДД.ММ (принимает ISO-строку или дату). */
|
||||
export const fmtAutocallDay = (value) => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || ""));
|
||||
return m ? `${m[3]}.${m[2]}` : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Текст слота для строки лога автодозвона.
|
||||
* Дату берём из строки лога (chosen_date), а для старых строк — из даты заказа,
|
||||
* если слот согласовал именно этот звонок. Если даты нет — показываем только
|
||||
* половину дня: лучше меньше, чем выдуманный день.
|
||||
*/
|
||||
export const autocallSlotText = (logRow, order) => {
|
||||
if (!logRow) return null;
|
||||
const dtmfDay = logRow.dtmf_day ?? logRow.dtmfDay;
|
||||
const dtmfHalf = logRow.dtmf_half ?? logRow.dtmfHalf;
|
||||
const half = HALF_LABEL[dtmfHalf];
|
||||
if (!dtmfDay && !half) return null;
|
||||
|
||||
const orderDate = CONFIRMED_OUTCOMES.includes(logRow.outcome)
|
||||
? fmtAutocallDay(order?.pickup_date || order?.pickupDate || order?.delivery_date || order?.deliveryDate)
|
||||
: null;
|
||||
const day = fmtAutocallDay(logRow.chosen_date || logRow.chosenDate) || orderDate;
|
||||
|
||||
if (day && half) return `${day}, ${half}`;
|
||||
if (day) return day;
|
||||
return half || null;
|
||||
};
|
||||
Loading…
Reference in New Issue