fix(логистика): бейдж обзвона не висит на согласованной заявке

Заявку согласовали — логист проставил дату вручную (delivery_date_source=manual)
или клиент подтвердил по ссылке. Планировщик такие заявки уже не обзванивает, но
бейдж последнего звонка («не дозвонились», «не выбрал день», «нужен менеджер»)
оставался и читался как состояние заявки рядом со «Согласовано».

resolveCallBadge: исходы no_answer/no_input_day/no_input_half/no_input_both/manual
скрываются, если заявка согласована (есть дата доставки/самовызова,
notification_status=confirmed или статус ушёл в логистику). Запреты
(opt_out/excluded/city_disabled) и подтверждение по звонку робота показываются
как раньше. +7 тестов, SW v136.
This commit is contained in:
Hermes 2026-10-05 15:00:30 +02:00
parent 50c9a68e9f
commit 34463bbc4a
3 changed files with 110 additions and 3 deletions

View File

@ -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-v135";
const STATIC_CACHE = "construction-delivery-static-v136";
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];

View File

@ -10,6 +10,12 @@
* «Не звонили» показываем только по заказам, которые ещё ждут решения
* (pending_confirmation / manual_confirmation_required): в уже доставленных
* заказах такая плашка — просто шум.
*
* То же правило действует для исходов «клиент не согласовал» (не дозвонились,
* не выбрал день/время, нужен менеджер): если заявку уже согласовали — логист
* проставил дату вручную или клиент подтвердил по ссылке — бейдж обзвона
* скрываем. Иначе рядом со «Согласовано» висело «📅 не выбрал день», то есть
* итог старого звонка читался как состояние заявки.
*/
import React from "react";
@ -18,6 +24,20 @@ import { AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
/** Статусы, при которых заказ ещё ждёт решения клиента. */
const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation_required"];
/** Статусы доставки: решение уже принято, заказ пошёл в логистику. */
const AGREED_DELIVERY_STATUSES = [
"agreed",
"driver_assigned",
"loaded",
"on_route",
"delivered",
"pickup",
"picked_up",
];
/** Исходы звонка «клиент не согласовал» — след истории, а не состояние заявки. */
const HISTORICAL_CALL_STATUSES = ["no_answer", "no_input_day", "no_input_half", "no_input_both", "manual"];
const STYLES = {
confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]",
no_answer: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
@ -51,12 +71,32 @@ export const isAwaitingDecision = (group) => {
return AWAITING_DELIVERY_STATUSES.includes(status);
};
/**
* Согласована ли заявка — тогда роботу по ней звонить нечего и бейдж обзвона
* не показываем. Признаки те же, по которым планировщик
* (scripts/autocall_scheduler.py) исключает заявку из обзвона: есть дата
* доставки или самовывоза, клиент подтвердил по ссылке (notification_status)
* либо заказ уже ушёл в логистику.
*/
export const isCallingStopped = (group) => {
if (!group) return false;
const notification = String(group.notificationStatus || group.notification_status || "").toLowerCase();
if (notification === "confirmed") return true;
if (group.deliveryDate || group.delivery_date || group.pickupDate || group.pickup_date) return true;
const status = String(group.deliveryStatus || group.delivery_status || "").toLowerCase();
return AGREED_DELIVERY_STATUSES.includes(status);
};
/** Что показать в строке: null — показывать нечего. */
export const resolveCallBadge = (group) => {
if (!group) return null;
const status = group.autocallStatus || group.autocall_status || "not_called";
// Запрет и исключение важны всегда, независимо от стадии заказа.
if (status === "not_called" && !isAwaitingDecision(group)) return null;
if (status === "not_called" && (!isAwaitingDecision(group) || isCallingStopped(group))) return null;
// Заявку уже согласовали — исход последнего звонка «не дозвонились / не выбрал
// день / нужен менеджер» стал историей: рядом со «Согласовано» он противоречит
// статусу заявки.
if (HISTORICAL_CALL_STATUSES.includes(status) && isCallingStopped(group)) return null;
const meta = AUTOCALL_STATUS_META[status];
if (!meta) return null;
return {

View File

@ -6,7 +6,7 @@
*/
import { describe, it, expect } from "vitest";
import { CallStatusBadge, isAwaitingDecision, resolveCallBadge } from "./CallStatusBadge";
import { CallStatusBadge, isAwaitingDecision, isCallingStopped, resolveCallBadge } from "./CallStatusBadge";
import { AUTOCALL_STATUS_META, AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
const group = (over = {}) => ({ id: "g1", deliveryStatus: "pending_confirmation", ...over });
@ -76,6 +76,73 @@ describe("resolveCallBadge — что видно в строке", () => {
});
});
describe("заявка уже согласована — бейджа обзвона нет", () => {
it("дату поставил логист вручную: «не выбрал день» больше не висит", () => {
expect(resolveCallBadge(group({
autocallStatus: "no_input_day",
deliveryStatus: "agreed",
deliveryDate: "2026-10-08",
deliveryDateSource: "manual",
notificationStatus: "confirmed",
}))).toBeNull();
});
it("клиент подтвердил по ссылке — исходы «не дозвонились» и «нужен менеджер» тоже скрыты", () => {
const over = { notificationStatus: "confirmed", deliveryStatus: "pending_confirmation" };
expect(resolveCallBadge(group({ ...over, autocallStatus: "no_answer" }))).toBeNull();
expect(resolveCallBadge(group({ ...over, autocallStatus: "manual" }))).toBeNull();
expect(resolveCallBadge(group({ ...over, autocallStatus: "no_input_half" }))).toBeNull();
expect(resolveCallBadge(group({ ...over, autocallStatus: "no_input_both" }))).toBeNull();
});
it("заказ ушёл в логистику (доставлен, самовывоз, в пути) — плашка не висит", () => {
for (const deliveryStatus of ["delivered", "picked_up", "on_route", "pickup"]) {
expect(resolveCallBadge(group({ autocallStatus: "no_answer", deliveryStatus }))).toBeNull();
}
});
it("дата есть, а статус ещё «ждёт подтверждения» — это не повод показывать «день не выбрал»", () => {
expect(resolveCallBadge(group({
autocallStatus: "no_input_day",
deliveryStatus: "pending_confirmation",
deliveryDate: "2026-10-08",
}))).toBeNull();
});
it("не согласовано — подписи остаются на месте", () => {
const badge = resolveCallBadge(group({ autocallStatus: "no_input_day", deliveryStatus: "pending_confirmation" }));
expect(badge.label).toContain("не выбрал день");
});
it("запрет и исключение важнее согласования — их не скрываем", () => {
const agreed = { deliveryStatus: "delivered", deliveryDate: "2026-10-01" };
expect(resolveCallBadge(group({ ...agreed, autocallStatus: "opt_out" })).label).toContain("не звонить");
expect(resolveCallBadge(group({ ...agreed, autocallStatus: "excluded" })).label).toContain("исключение");
expect(resolveCallBadge(group({ ...agreed, autocallStatus: "city_disabled" })).label).toContain("не звоним");
});
it("согласие по звонку робота видно и после отгрузки — оно не противоречит статусу", () => {
const badge = resolveCallBadge(group({ autocallStatus: "confirmed", deliveryStatus: "delivered" }));
expect(badge.label).toContain("согласовано");
});
});
describe("isCallingStopped", () => {
it("согласование видно по дате, подтверждению клиента или уходу в логистику", () => {
expect(isCallingStopped({ deliveryDate: "2026-10-08" })).toBe(true);
expect(isCallingStopped({ pickupDate: "2026-10-08" })).toBe(true);
expect(isCallingStopped({ notificationStatus: "confirmed" })).toBe(true);
expect(isCallingStopped({ delivery_status: "driver_assigned" })).toBe(true);
expect(isCallingStopped({ deliveryStatus: "agreed" })).toBe(true);
});
it("пустая и необработанная заявка не считается согласованной", () => {
expect(isCallingStopped(null)).toBe(false);
expect(isCallingStopped(group({}))).toBe(false);
expect(isCallingStopped(group({ deliveryStatus: "pending_confirmation", notificationStatus: "link_ready" }))).toBe(false);
});
});
describe("isAwaitingDecision", () => {
it("ждёт решения только ожидающий подтверждения заказ", () => {
expect(isAwaitingDecision({ deliveryStatus: "pending_confirmation" })).toBe(true);