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 { Pagination } from "../UI/Pagination";
|
||||
import { OrderFilters } from "../orders/OrderFilters";
|
||||
import { SmsProblemNotice } from "./SmsProblemNotice";
|
||||
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||
|
||||
|
|
@ -509,6 +510,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
|
|||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Причина, по которой SMS не уходят — до таблицы, чтобы не искать её в логе */}
|
||||
<SmsProblemNotice orderGroupIds={filteredGroups.map((g) => g.id)} />
|
||||
|
||||
{!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)]">
|
||||
По этому поиску ничего не найдено.
|
||||
|
|
|
|||
|
|
@ -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 { Badge } from "../UI/Badge";
|
||||
import { supabase } from "../../supabaseClient";
|
||||
import { getSmsFailureReason, isSmsFundsError } from "../../services/orderGroupViews";
|
||||
|
||||
// ── Status labels ────────────────────────────────────────────────────────────
|
||||
const NOTIF_LABELS = {
|
||||
|
|
@ -209,6 +210,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
const hasSecondSms = isDelivered(secondSmsLog);
|
||||
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.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 (
|
||||
<div className="p-4">
|
||||
|
|
@ -226,7 +234,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{hasFirstSms ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
||||
) : 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 ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
||||
) : notifStatus === "link_ready" ? (
|
||||
|
|
@ -245,7 +253,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{hasSecondSms ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
||||
) : 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 ? (
|
||||
<div className="text-[var(--color-text-muted)]">
|
||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||
|
|
@ -267,7 +275,14 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{/* Error */}
|
||||
{lastError && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -277,6 +277,65 @@ export const SMS_STATUS_META = {
|
|||
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) ──
|
||||
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
|
||||
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