fix(logistics): понятные статусы автодозвона в реестре доставок — своя подпись у каждого исхода, «не звонили» только по ожидающим заказам, убраны формулировки удалённых сценариев; единый список подписей для фильтра и бейджа

This commit is contained in:
Hermes 2026-09-16 11:03:28 +02:00
parent aeb4bbf63f
commit 7ab3debbb3
7 changed files with 165 additions and 43 deletions

View File

@ -1,34 +1,66 @@
/** /**
* @file CallStatusBadge.jsx * @file CallStatusBadge.jsx
* @description Бейдж коммуникационного статуса для строк реестра групп. * @description Бейдж автодозвона в строке реестра доставок.
* Показывает проблему автодозвона снаружи, не открывая карточку. *
* Приоритет сверху вниз — один бейдж наружу. * Раньше наружу выходили две безликие плашки: «Автодозвон ✅» и «Автодозвон ❌» —
* по ним нельзя было понять, не дозвонились, не нажали цифру или заказ ушёл
* менеджеру. Теперь у каждого состояния своя подпись, взятая из
* AUTOCALL_STATUS_META (тот же список, что в фильтрах, — не разъезжается).
*
* «Не звонили» показываем только по заказам, которые ещё ждут решения
* (pending_confirmation / manual_confirmation_required): в уже доставленных
* заказах такая плашка — просто шум.
*/ */
import React from "react"; import React from "react";
// Приоритет: отказ > исключение > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота import { AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
export const resolveCallBadge = (g) => {
if (!g) return null;
// 1. Отказ от звонков /** Статусы, при которых заказ ещё ждёт решения клиента. */
if (g.autocall_opt_out) { const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation_required"];
return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" };
}
// 2. Исключение по настройке (префикс/номер) — не звоним по условию const STYLES = {
if (g.autocall_exclusion_reason) { confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]",
return { label: "🚫 не звоним (исключение)", cls: "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]", title: `Исключение автодозвона: ${g.autocall_exclusion_reason}` }; no_answer: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
} no_input: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
manual: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
not_called: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
};
// 3. Результат автодозвона: ✅ согласовано / ❌ ошибка (не дозвонились, не нажал и т.п.) const TITLES = {
if (g.autocallStatus === "confirmed") { confirmed: "Клиент согласовал день и половину дня по звонку робота",
return { label: "🤖 Автодозвон ✅", cls: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]", title: "Клиент согласовал слот по автодозвону" }; no_answer: "Робот звонил, трубку не взяли",
} no_input: "Трубку взяли, но цифру в меню не нажали",
if (g.autocallStatus && g.autocallStatus !== "not_called") { manual: "Нужен менеджер: неверный номер или ошибка звонка",
return { label: "🤖 Автодозвон ❌", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Автодозвон не удался" }; opt_out: "Звонки по этому клиенту запрещены вручную",
} excluded: "Исключение: по этому номеру или городу не звоним",
not_called: "Автодозвона по заказу ещё не было",
};
return null; /** Ждёт ли заказ решения (тогда «не звонили» стоит показать). */
export const isAwaitingDecision = (group) => {
if (!group) return false;
const status = group.deliveryStatus || group.delivery_status || "";
return AWAITING_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;
const meta = AUTOCALL_STATUS_META[status];
if (!meta) return null;
return {
status,
label: `${meta.icon} ${meta.short}`,
cls: STYLES[status] || STYLES.not_called,
title: status === "excluded" && group.autocall_exclusion_reason
? `Исключение автодозвона: ${group.autocall_exclusion_reason}`
: TITLES[status] || meta.label,
};
}; };
export const CallStatusBadge = ({ group }) => { export const CallStatusBadge = ({ group }) => {

View File

@ -0,0 +1,87 @@
/**
* @file CallStatusBadge.test.jsx
* @description Бейдж автодозвона в реестре доставок: по плашке должно быть понятно,
* что с заказом — не звонили, согласовано, не дозвонились, не нажал цифру,
* нужен менеджер, запрет или исключение. Без «Автодозвон ❌» на всё подряд.
*/
import { describe, it, expect } from "vitest";
import { CallStatusBadge, isAwaitingDecision, resolveCallBadge } from "./CallStatusBadge";
import { AUTOCALL_STATUS_META, AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
const group = (over = {}) => ({ id: "g1", deliveryStatus: "pending_confirmation", ...over });
/** Текст из разметки без служебных пробелов — как в остальных тестах проекта. */
const plain = (text) => String(text).replace(/[\u00a0\u202f]/g, " ");
describe("resolveCallBadge — что видно в строке", () => {
it("не звонили и заказ ещё ждёт решения — плашка есть", () => {
const badge = resolveCallBadge(group({ autocallStatus: "not_called" }));
expect(badge.label).toContain("звонка не было");
expect(badge.title).toContain("Автодозвона по заказу ещё не было");
});
it("не звонили, но заказ уже доставлен — плашки нет (не засоряем таблицу)", () => {
expect(resolveCallBadge(group({ autocallStatus: "not_called", deliveryStatus: "delivered" }))).toBeNull();
});
it("у каждого исхода своя подпись, а не одна общая «Автодозвон ❌»", () => {
const labels = ["confirmed", "no_answer", "no_input", "manual"].map(
(status) => resolveCallBadge(group({ autocallStatus: status })).label,
);
expect(new Set(labels).size).toBe(labels.length);
expect(labels[0]).toContain("согласовано");
expect(labels[1]).toContain("не дозвонились");
expect(labels[2]).toContain("не нажал цифру");
expect(labels[3]).toContain("нужен менеджер");
});
it("запрет звонков и исключение видны даже в завершённом заказе", () => {
const stop = resolveCallBadge(group({ autocallStatus: "opt_out", deliveryStatus: "delivered" }));
expect(stop.label).toContain("не звонить");
const excluded = resolveCallBadge(group({
autocallStatus: "excluded",
deliveryStatus: "delivered",
autocall_exclusion_reason: "МТС-диапазон",
}));
expect(excluded.title).toContain("МТС-диапазон");
});
it("неизвестный статус не рисуем", () => {
expect(resolveCallBadge(group({ autocallStatus: "какой-то-старый" }))).toBeNull();
});
it("в разметке стоит именно подпись статуса", () => {
const html = CallStatusBadge({ group: group({ autocallStatus: "no_answer" }) });
expect(plain(html.props.className)).toContain("rounded-full");
expect(plain(html.props.children)).toContain("не дозвонились");
});
});
describe("isAwaitingDecision", () => {
it("ждёт решения только ожидающий подтверждения заказ", () => {
expect(isAwaitingDecision({ deliveryStatus: "pending_confirmation" })).toBe(true);
expect(isAwaitingDecision({ deliveryStatus: "manual_confirmation_required" })).toBe(true);
expect(isAwaitingDecision({ deliveryStatus: "delivered" })).toBe(false);
expect(isAwaitingDecision({ delivery_status: "agreed" })).toBe(false);
expect(isAwaitingDecision(null)).toBe(false);
});
});
describe("единый список статусов", () => {
it("фильтр и подписи не расходятся: у каждого пункта фильтра есть подпись", () => {
for (const option of AUTOCALL_STATUS_OPTIONS) {
expect(AUTOCALL_STATUS_META[option.value]).toBeTruthy();
}
expect(AUTOCALL_STATUS_OPTIONS).toHaveLength(Object.keys(AUTOCALL_STATUS_META).length);
});
it("в списках нет формулировок из удалённых сценариев", () => {
const all = [
...AUTOCALL_STATUS_OPTIONS.map((o) => o.label),
...Object.values(AUTOCALL_STATUS_META).map((m) => `${m.label} ${m.short}`),
].join(" ").toLowerCase();
expect(all).not.toContain("отказ от звонков");
expect(all).not.toContain("требует ручного управления");
});
});

View File

@ -29,8 +29,6 @@ const OUTCOME_META = {
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
}; };

View File

@ -20,8 +20,6 @@ const OUTCOME_META = {
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
}; };

View File

@ -16,8 +16,6 @@ const OUTCOME_META = {
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
}; };

View File

@ -4,6 +4,7 @@ import { Input } from "../UI/Input";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DateRangePicker } from "../UI/DateRangePicker"; import { DateRangePicker } from "../UI/DateRangePicker";
import { Switch } from "../UI/Switch"; import { Switch } from "../UI/Switch";
import { AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
const DropdownSelect = ({ value, options, onChange, placeholder }) => { const DropdownSelect = ({ value, options, onChange, placeholder }) => {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
@ -131,13 +132,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
const autocallStatusOptions = [ const autocallStatusOptions = [
{ value: "all", label: "Статус автодозвона" }, { value: "all", label: "Статус автодозвона" },
{ value: "not_called", label: "Не звонили" }, ...AUTOCALL_STATUS_OPTIONS,
{ value: "confirmed", label: "Согласовано" },
{ value: "no_answer", label: "Не дозвонились" },
{ value: "no_input", label: "Не нажал в меню" },
{ value: "manual", label: "Требует ручного управления" },
{ value: "opt_out", label: "Отказ от звонков" },
{ value: "excluded", label: "Не звоним (исключение)" },
]; ];
return ( return (

View File

@ -338,16 +338,30 @@ export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CO
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ── // ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
// Принимает массив autocall_log (отсортированный по created_at desc) или null. // Принимает массив autocall_log (отсортированный по created_at desc) или null.
// ВАЖНО: те же значения приходят из базы (функция autocall_status(order_groups)) —
// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся.
// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры.
export const AUTOCALL_STATUS_META = { export const AUTOCALL_STATUS_META = {
not_called: { label: "Не звонили", tone: "neutral" }, not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
confirmed: { label: "Согласовано", tone: "accent" }, confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
no_answer: { label: "Не дозвонились", tone: "danger" }, no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
no_input: { label: "Не нажал в меню", tone: "warning" }, no_input: { label: "Не нажал в меню", short: "не нажал цифру", tone: "warning", icon: "🤐" },
manual: { label: "Требует ручного управления", tone: "warning" }, manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" },
opt_out: { label: "Отказ от звонков", tone: "danger" }, opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" },
excluded: { label: "Не звоним (исключение)", tone: "warning" }, excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" },
}; };
/** Порядок для выпадающих списков — один список на всё приложение. */
export const AUTOCALL_STATUS_OPTIONS = [
{ value: "not_called", label: "Не звонили" },
{ value: "confirmed", label: "Согласовано" },
{ value: "no_answer", label: "Не дозвонились" },
{ value: "no_input", label: "Не нажал в меню" },
{ value: "manual", label: "Нужен менеджер" },
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
{ value: "excluded", label: "Не звоним (исключение)" },
];
export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => { export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => {
if (optOut) return "opt_out"; if (optOut) return "opt_out";
if (exclusionReason) return "excluded"; if (exclusionReason) return "excluded";
@ -508,7 +522,7 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage }, { value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, { value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
{ value: "status:manual_required", label: "Требует ручной обработки" }, { value: "status:manual_required", label: "Нужен менеджер" },
{ value: "status:ready_for_notification", label: "Готово к уведомлению" }, { value: "status:ready_for_notification", label: "Готово к уведомлению" },
{ value: "status:ready_to_launch", label: "Готово к запуску" }, { value: "status:ready_to_launch", label: "Готово к запуску" },
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent }, { value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },