fix(logistics): понятные статусы автодозвона в реестре доставок — своя подпись у каждого исхода, «не звонили» только по ожидающим заказам, убраны формулировки удалённых сценариев; единый список подписей для фильтра и бейджа
This commit is contained in:
parent
aeb4bbf63f
commit
7ab3debbb3
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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("требует ручного управления");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue