feat(orders): причина неудачной отправки SMS — в карточке заказа и блоком над таблицей наборов (код 201 = кончились средства)
This commit is contained in:
parent
6ebdc41667
commit
dc262d2be7
|
|
@ -26,6 +26,7 @@ import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonPage } from "../UI/Loading";
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
import { Pagination } from "../UI/Pagination";
|
import { Pagination } from "../UI/Pagination";
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
|
import { SmsProblemNotice } from "./SmsProblemNotice";
|
||||||
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
|
@ -509,6 +510,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Причина, по которой SMS не уходят — до таблицы, чтобы не искать её в логе */}
|
||||||
|
<SmsProblemNotice orderGroupIds={filteredGroups.map((g) => g.id)} />
|
||||||
|
|
||||||
{!totalGroups ? (
|
{!totalGroups ? (
|
||||||
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* @file SmsProblemNotice.jsx
|
||||||
|
* @description Объясняет причину, по которой SMS не уходят.
|
||||||
|
*
|
||||||
|
* В таблице наборов видно только «SMS не отправлено», а причина лежит в логе
|
||||||
|
* отправок цифрой (например, 201 — на счёте SMS-сервиса закончились средства).
|
||||||
|
* Блок читает последние неудачные отправки, складывает их по причинам
|
||||||
|
* (summarizeSmsFailures) и показывает причину словами над таблицей.
|
||||||
|
*
|
||||||
|
* Если неудачных отправок нет — блок не показывается вообще.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
import { summarizeSmsFailures } from "../../services/smsProblemSummary";
|
||||||
|
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
|
||||||
|
|
||||||
|
const LOOKBACK_DAYS = 30;
|
||||||
|
const FAILED_STATUSES = ["send_failed", "error", "limit_exceeded"];
|
||||||
|
|
||||||
|
export function SmsProblemNotice({ orderGroupIds = [], className = "" }) {
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const visibleKey = useMemo(() => orderGroupIds.join(","), [orderGroupIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("order_group_id, campaign_type, status, sms_code, error_message, created_at")
|
||||||
|
.in("status", FAILED_STATUSES)
|
||||||
|
.gte("created_at", since)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1000);
|
||||||
|
if (error) throw error;
|
||||||
|
if (alive) setSummary(summarizeSmsFailures(data || [], orderGroupIds));
|
||||||
|
} catch {
|
||||||
|
// Лог может быть недоступен для роли — тогда просто ничего не показываем.
|
||||||
|
if (alive) setSummary(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [visibleKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return <SmsProblemNoticeView summary={summary} className={className} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* @file SmsProblemNotice.test.jsx
|
||||||
|
* @description Блок над таблицей: причина «закончились средства» написана словами,
|
||||||
|
* видно период и число заказов; без причины блок не рисуется.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
|
||||||
|
|
||||||
|
const fundsSummary = {
|
||||||
|
reason: "на счёте SMS-сервиса закончились средства",
|
||||||
|
messages: 42,
|
||||||
|
topMessages: 42,
|
||||||
|
groups: 8,
|
||||||
|
affectedInTable: 3,
|
||||||
|
hasVisibleRows: true,
|
||||||
|
hasFundsReason: true,
|
||||||
|
firstAt: "2026-09-10T10:06:11Z",
|
||||||
|
lastAt: "2026-09-13T06:03:03Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SmsProblemNoticeView", () => {
|
||||||
|
it("пишет, что закончились средства, и что делать", () => {
|
||||||
|
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={fundsSummary} />);
|
||||||
|
|
||||||
|
expect(html).toContain("SMS не отправлено: на счёте SMS-сервиса закончились средства");
|
||||||
|
expect(html).toContain("8 заказов");
|
||||||
|
expect(html).toContain("42 сообщения");
|
||||||
|
expect(html).toContain("В таблице ниже — 3 из них.");
|
||||||
|
expect(html).toContain("Пополните счёт SMS-сервиса");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("для другой причины подсказка другая", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<SmsProblemNoticeView
|
||||||
|
summary={{ ...fundsSummary, reason: "неверный номер получателя", hasFundsReason: false, groups: 1 }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("SMS не отправлено: неверный номер получателя");
|
||||||
|
expect(html).toContain("1 заказ");
|
||||||
|
expect(html).toContain("разделе «SMS-рассылки»");
|
||||||
|
expect(html).not.toContain("Пополните счёт");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("одно сообщение — «сообщение», одно число — «заказ»", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<SmsProblemNoticeView
|
||||||
|
summary={{ ...fundsSummary, groups: 1, messages: 1, firstAt: "2026-09-10T10:06:11Z", lastAt: "2026-09-10T10:06:11Z" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("1 заказ");
|
||||||
|
expect(html).toContain("1 сообщение");
|
||||||
|
expect(html).toContain("за 10.09");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без сводки ничего не рисуется", () => {
|
||||||
|
expect(renderToStaticMarkup(<SmsProblemNoticeView summary={null} />)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* @file SmsProblemNoticeView.jsx
|
||||||
|
* @description Разметка блока «почему SMS не уходят».
|
||||||
|
*
|
||||||
|
* Отдельно от загрузки данных: сам блок только рисует то, что посчитал
|
||||||
|
* summarizeSmsFailures. Так разметку можно проверить без сети и Supabase.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { plural } from "../../services/smsProblemSummary";
|
||||||
|
|
||||||
|
const fmtDay = (iso) => {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmsProblemNoticeView = ({ summary, className = "" }) => {
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const period = summary.firstAt
|
||||||
|
? summary.firstAt === summary.lastAt
|
||||||
|
? `за ${fmtDay(summary.firstAt)}`
|
||||||
|
: `с ${fmtDay(summary.firstAt)} по ${fmtDay(summary.lastAt)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const groupsText = `${summary.groups} ${plural(summary.groups, "заказ", "заказа", "заказов")}`;
|
||||||
|
const messagesText = `${summary.messages} ${plural(summary.messages, "сообщение", "сообщения", "сообщений")}`;
|
||||||
|
const tableText =
|
||||||
|
summary.affectedInTable > 0
|
||||||
|
? `В таблице ниже — ${summary.affectedInTable} из них.`
|
||||||
|
: summary.hasVisibleRows
|
||||||
|
? "В текущей выборке таблицы таких заказов нет."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"rounded-[28px] border p-4 text-sm",
|
||||||
|
summary.hasFundsReason
|
||||||
|
? "border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)]"
|
||||||
|
: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]",
|
||||||
|
className,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-base leading-none">{summary.hasFundsReason ? "⛔" : "⚠️"}</span>
|
||||||
|
<span className="font-semibold text-[var(--color-text)]">
|
||||||
|
SMS не отправлено: {summary.reason}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||||
|
summary.hasFundsReason
|
||||||
|
? "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]"
|
||||||
|
: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{groupsText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{messagesText}
|
||||||
|
{period ? ` · ${period}` : ""}
|
||||||
|
{tableText ? ` · ${tableText}` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-[var(--color-text)]">
|
||||||
|
{summary.hasFundsReason
|
||||||
|
? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно."
|
||||||
|
: "Проверьте настройки отправки SMS в разделе «SMS-рассылки»."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
|
import { getSmsFailureReason, isSmsFundsError } from "../../services/orderGroupViews";
|
||||||
|
|
||||||
// ── Status labels ────────────────────────────────────────────────────────────
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
const NOTIF_LABELS = {
|
const NOTIF_LABELS = {
|
||||||
|
|
@ -209,6 +210,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
const hasSecondSms = isDelivered(secondSmsLog);
|
const hasSecondSms = isDelivered(secondSmsLog);
|
||||||
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
|
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
|
||||||
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
|
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
|
||||||
|
// Причина неудачи словами: в логе лежит только код сервиса (201, 202, ...).
|
||||||
|
const failureReason = (log) => {
|
||||||
|
if (!log) return null;
|
||||||
|
if (log.status === "expired") return "срок доставки сообщения истёк";
|
||||||
|
return getSmsFailureReason(log) || log.status;
|
||||||
|
};
|
||||||
|
const fundsProblem = [firstSmsLog, secondSmsLog].some((log) => isSmsFundsError(log));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
@ -226,7 +234,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{hasFirstSms ? (
|
{hasFirstSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
||||||
) : firstSmsFailed ? (
|
) : firstSmsFailed ? (
|
||||||
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}</div>
|
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ не отправлено: {failureReason(firstSmsLog)}</div>
|
||||||
) : firstSmsAt ? (
|
) : firstSmsAt ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
||||||
) : notifStatus === "link_ready" ? (
|
) : notifStatus === "link_ready" ? (
|
||||||
|
|
@ -245,7 +253,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{hasSecondSms ? (
|
{hasSecondSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
||||||
) : secondSmsFailed ? (
|
) : secondSmsFailed ? (
|
||||||
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}</div>
|
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ не отправлено: {failureReason(secondSmsLog)}</div>
|
||||||
) : notifStatus === "first_sms_sent" && countdown ? (
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
<div className="text-[var(--color-text-muted)]">
|
<div className="text-[var(--color-text-muted)]">
|
||||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
|
@ -267,7 +275,14 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{lastError && (
|
{lastError && (
|
||||||
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
⚠ {lastError}
|
⚠ {typeof lastError === "string" ? getSmsFailureReason(lastError) || lastError : lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Нехватка средств — самая частая причина, объясняем прямо в карточке */}
|
||||||
|
{fundsProblem && (
|
||||||
|
<div className="rounded-lg border border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
|
⛔ На счёте SMS-сервиса закончились средства — сообщение не ушло. Пополните счёт: отправка возобновится автоматически.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,65 @@ export const SMS_STATUS_META = {
|
||||||
error: { label: "Ошибка", tone: "danger" },
|
error: { label: "Ошибка", tone: "danger" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Причина неудачной отправки SMS ────────────────────────────────
|
||||||
|
// Коды SMS-сервиса (первая строка ответа) → человеческая причина.
|
||||||
|
// В логе лежит только цифра, поэтому расшифровываем её здесь.
|
||||||
|
export const SMS_ERROR_REASONS = {
|
||||||
|
"100": "сообщение в очереди SMS-сервиса",
|
||||||
|
"101": "сообщение передано оператору",
|
||||||
|
"102": "сообщение в пути",
|
||||||
|
"104": "истёк срок доставки сообщения",
|
||||||
|
"105": "сообщение удалено оператором",
|
||||||
|
"106": "сбой на телефоне абонента",
|
||||||
|
"107": "оператор не сообщил причину",
|
||||||
|
"108": "оператор отклонил сообщение",
|
||||||
|
"130": "лимит сообщений на этот номер в сутки",
|
||||||
|
"131": "лимит одинаковых сообщений в минуту",
|
||||||
|
"132": "лимит одинаковых сообщений в сутки",
|
||||||
|
"200": "неверный ключ доступа к SMS-сервису",
|
||||||
|
"201": "на счёте SMS-сервиса закончились средства",
|
||||||
|
"202": "неверный номер получателя",
|
||||||
|
"203": "пустой текст сообщения",
|
||||||
|
"230": "исчерпан общий суточный лимит аккаунта",
|
||||||
|
"231": "лимит одинаковых сообщений в минуту",
|
||||||
|
"232": "лимит одинаковых сообщений в сутки",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Код нехватки средств — по нему показываем отдельное предупреждение.
|
||||||
|
export const SMS_FUNDS_ERROR_CODE = "201";
|
||||||
|
|
||||||
|
const smsErrorCode = (log) => {
|
||||||
|
if (!log) return "";
|
||||||
|
const direct = String(log.sms_code ?? "").trim();
|
||||||
|
if (direct) return direct.match(/\d{3}/)?.[0] || direct;
|
||||||
|
const fromText = String(log.error_message ?? "").trim();
|
||||||
|
return fromText.match(/\d{3}/)?.[0] || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причина, по которой SMS не ушла — словами. Принимает строку лога
|
||||||
|
* (sms_campaign_log) либо значение lastSmsError у группы.
|
||||||
|
*/
|
||||||
|
export const getSmsFailureReason = (log) => {
|
||||||
|
if (!log) return null;
|
||||||
|
// В некоторых местах причина лежит просто строкой: "201" или "201\n"
|
||||||
|
if (typeof log === "string") {
|
||||||
|
const text = log.trim();
|
||||||
|
const onlyCode = text.match(/^(\d{3})$/)?.[1];
|
||||||
|
if (onlyCode && SMS_ERROR_REASONS[onlyCode]) return SMS_ERROR_REASONS[onlyCode];
|
||||||
|
return text.slice(0, 120) || null;
|
||||||
|
}
|
||||||
|
const code = smsErrorCode(log);
|
||||||
|
if (code && SMS_ERROR_REASONS[code]) return SMS_ERROR_REASONS[code];
|
||||||
|
const raw = String(log.error_message ?? "").trim();
|
||||||
|
if (code) return `код ${code}`;
|
||||||
|
if (!raw) return null;
|
||||||
|
return raw.slice(0, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Нехватка средств на счёте SMS-сервиса — самая частая и заметная причина. */
|
||||||
|
export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CODE;
|
||||||
|
|
||||||
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
|
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
|
||||||
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
|
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
|
||||||
export const AUTOCALL_STATUS_META = {
|
export const AUTOCALL_STATUS_META = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* @file orderGroupViews.smsReasons.test.js
|
||||||
|
* @description Причина неудачной отправки SMS должна быть понятна человеку:
|
||||||
|
* в логе лежит код сервиса (201), а показывать нужно «закончились средства».
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
getSmsFailureReason,
|
||||||
|
isSmsFundsError,
|
||||||
|
SMS_ERROR_REASONS,
|
||||||
|
getSmsProblemTone,
|
||||||
|
} from "./orderGroupViews";
|
||||||
|
|
||||||
|
describe("getSmsFailureReason", () => {
|
||||||
|
it("расшифровывает код 201 — закончились средства", () => {
|
||||||
|
expect(getSmsFailureReason({ sms_code: "201", error_message: "201" }))
|
||||||
|
.toBe("на счёте SMS-сервиса закончились средства");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("понимает код, пришедший строкой с переводом строки", () => {
|
||||||
|
expect(getSmsFailureReason("201\n")).toBe("на счёте SMS-сервиса закончились средства");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("берёт код из текста ошибки, если колонка кода пуста", () => {
|
||||||
|
expect(getSmsFailureReason({ sms_code: null, error_message: "202" }))
|
||||||
|
.toBe("неверный номер получателя");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("неизвестный код показывает как код, а не как пустоту", () => {
|
||||||
|
expect(getSmsFailureReason({ sms_code: "999" })).toBe("код 999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без данных возвращает null", () => {
|
||||||
|
expect(getSmsFailureReason(null)).toBeNull();
|
||||||
|
expect(getSmsFailureReason({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("все коды из таблицы расшифрованы человеческими словами", () => {
|
||||||
|
for (const [code, text] of Object.entries(SMS_ERROR_REASONS)) {
|
||||||
|
expect(text).not.toMatch(/^\d{3}$/);
|
||||||
|
expect(getSmsFailureReason({ sms_code: code })).toBe(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSmsFundsError", () => {
|
||||||
|
it("распознаёт нехватку средств", () => {
|
||||||
|
expect(isSmsFundsError({ sms_code: "201" })).toBe(true);
|
||||||
|
expect(isSmsFundsError({ error_message: "201\n" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не считает нехваткой средств другие ошибки", () => {
|
||||||
|
expect(isSmsFundsError({ sms_code: "202" })).toBe(false);
|
||||||
|
expect(isSmsFundsError(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("подсветка SMS в таблице остаётся прежней", () => {
|
||||||
|
it("без отправленных SMS — «не отправлено»", () => {
|
||||||
|
expect(getSmsProblemTone({})).toBe("not_sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отправлено с ошибкой — «не доставлено»", () => {
|
||||||
|
expect(getSmsProblemTone({ firstSmsSentAt: "2026-09-13T06:00:00Z", lastSmsError: "201" }))
|
||||||
|
.toBe("not_delivered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* @file smsProblemSummary.js
|
||||||
|
* @description Складывает неудачные отправки SMS в одну понятную причину.
|
||||||
|
*
|
||||||
|
* В таблице видно только «SMS не отправлено», а причина в логе — код сервиса.
|
||||||
|
* Здесь считаем: сколько сообщений, сколько заказов, за какой период и что
|
||||||
|
* именно случилось чаще всего (например, что на счёте закончились средства).
|
||||||
|
*/
|
||||||
|
import { getSmsFailureReason } from "./orderGroupViews";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array} rows строки sms_campaign_log со статусами ошибок
|
||||||
|
* @param {Array<string>} visibleIds id заказов, которые сейчас видны в таблице
|
||||||
|
* @returns {null|{reason, messages, groups, affectedInTable, hasFundsReason, firstAt, lastAt}}
|
||||||
|
*/
|
||||||
|
export const summarizeSmsFailures = (rows = [], visibleIds = []) => {
|
||||||
|
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
||||||
|
if (!list.length) return null;
|
||||||
|
|
||||||
|
const reasons = new Map();
|
||||||
|
const lastByGroup = new Map();
|
||||||
|
let firstAt = null;
|
||||||
|
let lastAt = null;
|
||||||
|
|
||||||
|
for (const row of list) {
|
||||||
|
const reason = getSmsFailureReason(row) || "причина не указана";
|
||||||
|
reasons.set(reason, (reasons.get(reason) || 0) + 1);
|
||||||
|
|
||||||
|
if (row.order_group_id && !lastByGroup.has(row.order_group_id)) {
|
||||||
|
lastByGroup.set(row.order_group_id, row);
|
||||||
|
}
|
||||||
|
if (row.created_at) {
|
||||||
|
if (!firstAt || row.created_at < firstAt) firstAt = row.created_at;
|
||||||
|
if (!lastAt || row.created_at > lastAt) lastAt = row.created_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reason, topMessages] = [...reasons.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||||
|
const visible = new Set(visibleIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reason,
|
||||||
|
messages: list.length,
|
||||||
|
topMessages,
|
||||||
|
groups: lastByGroup.size,
|
||||||
|
affectedInTable: [...lastByGroup.keys()].filter((id) => visible.has(id)).length,
|
||||||
|
hasVisibleRows: visible.size > 0,
|
||||||
|
hasFundsReason: /закончились средства/.test(reason),
|
||||||
|
firstAt,
|
||||||
|
lastAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** «сообщение / сообщения / сообщений», «заказ / заказа / заказов». */
|
||||||
|
export const plural = (n, one, few, many) => {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return one;
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
|
||||||
|
return many;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* @file smsProblemSummary.test.js
|
||||||
|
* @description Причина неудачных SMS для блока над таблицей: считаем по логу,
|
||||||
|
* чаще всего встречающуюся причину пишем словами, пустой лог — ничего не показываем.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { summarizeSmsFailures } from "./smsProblemSummary";
|
||||||
|
|
||||||
|
const row = (over = {}) => ({
|
||||||
|
order_group_id: "g1",
|
||||||
|
campaign_type: "first_sms",
|
||||||
|
status: "send_failed",
|
||||||
|
sms_code: "201",
|
||||||
|
error_message: "201\n",
|
||||||
|
created_at: "2026-09-10T10:06:11Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeSmsFailures", () => {
|
||||||
|
it("объясняет нехватку средств и считает заказы с сообщениями", () => {
|
||||||
|
const summary = summarizeSmsFailures([
|
||||||
|
row({ order_group_id: "g1", created_at: "2026-09-10T10:06:11Z" }),
|
||||||
|
row({ order_group_id: "g2", created_at: "2026-09-13T06:03:03Z" }),
|
||||||
|
row({ order_group_id: "g2", created_at: "2026-09-13T06:03:04Z" }),
|
||||||
|
], ["g1", "g2"]);
|
||||||
|
|
||||||
|
expect(summary.reason).toBe("на счёте SMS-сервиса закончились средства");
|
||||||
|
expect(summary.hasFundsReason).toBe(true);
|
||||||
|
expect(summary.groups).toBe(2);
|
||||||
|
expect(summary.messages).toBe(3);
|
||||||
|
expect(summary.affectedInTable).toBe(2);
|
||||||
|
expect(summary.firstAt).toBe("2026-09-10T10:06:11Z");
|
||||||
|
expect(summary.lastAt).toBe("2026-09-13T06:03:03Z".replace("06:03:03", "06:03:04"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не считает заказ в таблице, если его там нет", () => {
|
||||||
|
const summary = summarizeSmsFailures([row({ order_group_id: "g9" })], ["g1"]);
|
||||||
|
expect(summary.affectedInTable).toBe(0);
|
||||||
|
expect(summary.hasVisibleRows).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("берёт самую частую причину, когда их несколько", () => {
|
||||||
|
const summary = summarizeSmsFailures([
|
||||||
|
row({ order_group_id: "g1", sms_code: "202", error_message: "202" }),
|
||||||
|
row({ order_group_id: "g2" }),
|
||||||
|
row({ order_group_id: "g3" }),
|
||||||
|
], []);
|
||||||
|
|
||||||
|
expect(summary.reason).toBe("на счёте SMS-сервиса закончились средства");
|
||||||
|
expect(summary.topMessages).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("пустой лог — нет повода что-то показывать", () => {
|
||||||
|
expect(summarizeSmsFailures([], ["g1"])).toBeNull();
|
||||||
|
expect(summarizeSmsFailures(null, [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без id заказов причина всё равно объясняется", () => {
|
||||||
|
const summary = summarizeSmsFailures([row()], []);
|
||||||
|
expect(summary.hasFundsReason).toBe(true);
|
||||||
|
expect(summary.hasVisibleRows).toBe(false);
|
||||||
|
expect(summary.affectedInTable).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue