From 16141fe5b43e962457fbdb6adb731a3c0a4943f2 Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 28 Sep 2026 14:56:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20=D1=88=D0=B0=D0=B3=20=C2=AB?= =?UTF-8?q?=D0=97=D0=B2=D0=BE=D0=BD=D0=BE=D0=BA=C2=BB=20=D0=B2=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D1=87=D0=BA=D0=B5=20=D0=B7=D0=B0=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В блоке «История действий по заказу» автодозвон не был виден в цепочке шагов — там были только «Добавлен», «SMS», «Согласовано» и далее, хотя робот звонит по этим же заказам. Теперь шаг «Звонок» идёт сразу после SMS в обеих цепочках (доставка и самовывоз) и имеет собственное состояние: ✓ пройден, если робот согласовал слот; янтарный — если звонили без результата; серый — если звонков не было. Шаг не считается пройденным только потому, что заказ уже согласован (согласовать могли по ссылке из SMS, без звонка). Подсказка при наведении — последний исход и число звонков. SW: v119 -> v120. --- public/service-worker.js | 2 +- .../orders/OrderHistoryTimeline.jsx | 91 ++++++++++++++----- .../orders/OrderHistoryTimeline.test.jsx | 83 +++++++++++++++++ 3 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 src/components/orders/OrderHistoryTimeline.test.jsx diff --git a/public/service-worker.js b/public/service-worker.js index adbfca9..8d5f0f4 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v119"; + const STATIC_CACHE = "construction-delivery-static-v120"; const RUNTIME_CACHE = "construction-delivery-runtime-v119"; const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; diff --git a/src/components/orders/OrderHistoryTimeline.jsx b/src/components/orders/OrderHistoryTimeline.jsx index 1f5a8b1..90727c7 100644 --- a/src/components/orders/OrderHistoryTimeline.jsx +++ b/src/components/orders/OrderHistoryTimeline.jsx @@ -170,9 +170,17 @@ const getFieldChangeLabel = (changes) => { }; // ─── Progress Stepper ──────────────────────────────────────────────── +// Цепочка шагов заказа. Шаг «Звонок» стоит сразу за SMS: автодозвон — такой же +// канал связи с клиентом, как SMS, и в цепочке он обязан быть виден (иначе по +// карточке нельзя понять, что робот звонил). Состояние этого шага СВОЁ: он не +// считается пройденным только потому, что заказ уже согласован, — согласовать +// могли по ссылке из SMS, вообще без звонка. +const AUTOCALL_CONFIRMED_OUTCOMES = ["confirmed", "confirmed_silent", "confirmed_kept_previous"]; + const STAGES_DELIVERY = [ { key: "created", icon: "📥", label: "Добавлен" }, { key: "sms", icon: "📨", label: "SMS" }, + { key: "autocall", icon: "🤖", label: "Звонок" }, { key: "agreed", icon: "✅", label: "Согласовано" }, { key: "driver", icon: "🚚", label: "Водитель" }, { key: "delivered", icon: "📦", label: "Доставлено" }, @@ -181,10 +189,31 @@ const STAGES_DELIVERY = [ const STAGES_PICKUP = [ { key: "created", icon: "📥", label: "Добавлен" }, { key: "sms", icon: "📨", label: "SMS" }, + { key: "autocall", icon: "🤖", label: "Звонок" }, { key: "agreed", icon: "✅", label: "Согласовано" }, { key: "picked_up", icon: "📦", label: "Вывезено" }, ]; +// Состояние шага «Звонок» по журналу автодозвона: +// done — робот согласовал слот; warn — звонили, слот не согласован; empty — звонков не было. +export const getAutocallStepState = (autocallLogs = []) => { + if (!Array.isArray(autocallLogs) || autocallLogs.length === 0) return "empty"; + if (autocallLogs.some((l) => AUTOCALL_CONFIRMED_OUTCOMES.includes(l?.outcome))) return "done"; + return "warn"; +}; + +// Подсказка к шагу (title на десктопе): сколько было звонков и чем кончился последний. +export const autocallStepHint = (autocallLogs = [], order = null) => { + if (!Array.isArray(autocallLogs) || autocallLogs.length === 0) return "Звонков не было"; + const last = autocallLogs[0]; + const meta = AUTOCALL_OUTCOME_LABELS[last?.outcome]; + const what = meta ? meta.label.replace(/^Автодозвон:\s*/, "") : (last?.outcome || "—"); + const slot = autocallSlotText(last, order); + return `Звонков: ${autocallLogs.length} · последний: ${what}${slot ? ` (${slot})` : ""}${last?.was_test_mode ? " · 🧪 тест" : ""}`; +}; + +const stageIndex = (stages, key) => stages.findIndex((s) => s.key === key); + const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => { const deliveryStatus = order?.deliveryStatus || order?.delivery_status || ""; const notifStatus = order?.notificationStatus || order?.notification_status || ""; @@ -203,32 +232,33 @@ const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => { } if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) { - return 3; + return stageIndex(stages, "driver"); } if (deliveryStatus === "agreed" || notifStatus === "confirmed") { - return 2; + return stageIndex(stages, "agreed"); } // Автодозвон: если робот согласовал слот — это уже «Согласовано» - const hasAutocallConfirmed = autocallLogs.some((l) => ["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome)); + const hasAutocallConfirmed = autocallLogs.some((l) => AUTOCALL_CONFIRMED_OUTCOMES.includes(l.outcome)); if (hasAutocallConfirmed) { - return 2; + return stageIndex(stages, "agreed"); } // SMS stage counts as done when any first_sms record exists (even failed), not just when delivered const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms"); if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) { - return 1; + return stageIndex(stages, "sms"); } return 0; }; -const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => { +export const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => { const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup"; const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY; const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs); + const autocallState = getAutocallStepState(autocallLogs); if (activeIdx === -1) { return ( @@ -255,35 +285,50 @@ const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => { ); } + const circleStyles = { + done: "bg-[var(--color-accent-soft)] text-[var(--color-accent)]", + active: "bg-[var(--color-accent)] text-white ring-2 ring-[var(--color-accent)] ring-offset-2 ring-offset-[var(--color-surface)]", + warn: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)] ring-1 ring-[rgba(245,158,11,0.45)]", + idle: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", + }; + const labelStyles = { + done: "font-medium text-[var(--color-accent)]", + active: "font-bold text-[var(--color-text)]", + warn: "font-semibold text-[var(--color-warning)]", + idle: "text-[var(--color-text-muted)]", + }; + return (
{stages.map((stage, idx) => { - const isDone = idx < activeIdx; + const isAutocall = stage.key === "autocall"; const isActive = idx === activeIdx; + // У шага «Звонок» своё состояние — «пройден» он только при согласованном роботом слоте. + const visual = isAutocall + ? (autocallState === "done" ? "done" : autocallState === "warn" ? "warn" : "idle") + : isActive + ? "active" + : idx < activeIdx + ? "done" + : "idle"; + const lineDone = idx <= activeIdx; return ( {idx > 0 && ( -
)} -
+
- {isDone ? "✓" : stage.icon} + {visual === "done" ? "✓" : stage.icon}
- + {stage.label}
diff --git a/src/components/orders/OrderHistoryTimeline.test.jsx b/src/components/orders/OrderHistoryTimeline.test.jsx new file mode 100644 index 0000000..0582478 --- /dev/null +++ b/src/components/orders/OrderHistoryTimeline.test.jsx @@ -0,0 +1,83 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ProgressStepper, getAutocallStepState, autocallStepHint } from "./OrderHistoryTimeline"; + +const orderDelivery = { + id: "g-1", + deliveryType: "delivery", + deliveryStatus: "pending_confirmation", + notificationStatus: "first_sms_sent", + firstSmsSentAt: "2026-09-27T10:00:00Z", +}; + +const orderPickup = { + id: "g-2", + deliveryType: "pickup", + deliveryStatus: "pending_confirmation", + notificationStatus: "first_sms_sent", + firstSmsSentAt: "2026-09-27T10:00:00Z", +}; + +const call = (outcome, extra = {}) => ({ + id: Math.random().toString(36).slice(2), + outcome, + attempt_no: 1, + created_at: "2026-09-28T10:00:00Z", + was_test_mode: false, + ...extra, +}); + +describe("шаг «Звонок» в цепочке заказа", () => { + it("цепочка доставки содержит шаг звонка сразу после SMS", () => { + const markup = renderToStaticMarkup(); + const labels = ["Добавлен", "SMS", "Звонок", "Согласовано", "Водитель", "Доставлено"]; + let prev = -1; + for (const l of labels) { + const at = markup.indexOf(`>${l}`); + expect(at, `шаг «${l}» есть в цепочке`).toBeGreaterThan(-1); + expect(at, `шаг «${l}» идёт по порядку`).toBeGreaterThan(prev); + prev = at; + } + }); + + it("цепочка самовывоза тоже содержит звонок и заканчивается «Вывезено»", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain(">Звонок"); + expect(markup).toContain(">Вывезено"); + expect(markup).not.toContain(">Водитель"); + }); + + it("без звонков шаг «Звонок» не отмечен пройденным", () => { + const markup = renderToStaticMarkup(); + expect(getAutocallStepState([])).toBe("empty"); + // ни один ✓ не должен стоять на шаге звонка: до звонка и на нём самом пройдены только Добавлен и SMS + const callSegment = markup.slice(markup.indexOf(">Звонок") - 400, markup.indexOf(">Звонок")); + expect(callSegment.split("✓").length - 1).toBe(0); + }); + + it("согласованный роботом слот отмечает шаг «Звонок» пройденным", () => { + const logs = [call("confirmed")]; + const markup = renderToStaticMarkup(); + expect(getAutocallStepState(logs)).toBe("done"); + const callSegment = markup.slice(markup.indexOf(">Звонок") - 400, markup.indexOf(">Звонок")); + expect(callSegment).toContain("✓"); + }); + + it("неудачные звонки дают состояние «требует внимания», а не «пройдено»", () => { + const logs = [call("no_answer")]; + expect(getAutocallStepState(logs)).toBe("warn"); + const markup = renderToStaticMarkup(); + const callSegment = markup.slice(markup.indexOf(">Звонок") - 400, markup.indexOf(">Звонок")); + expect(callSegment).not.toContain("✓"); + expect(callSegment).toContain("🤖"); + expect(callSegment).toContain("--color-warning"); + }); + + it("подсказка к шагу описывает последний звонок словами логиста", () => { + expect(autocallStepHint([])).toBe("Звонков не было"); + expect(autocallStepHint([call("no_answer")])).toBe("Звонков: 1 · последний: не взял трубку"); + expect(autocallStepHint([call("confirmed", { was_test_mode: true })])) + .toBe("Звонков: 1 · последний: клиент согласовал слот · 🧪 тест"); + }); +});