feat(orders): причина неудачной отправки SMS — в карточке заказа и блоком над таблицей наборов (код 201 = кончились средства)

This commit is contained in:
Hermes 2026-09-14 15:40:15 +02:00
parent 6ebdc41667
commit dc262d2be7
9 changed files with 461 additions and 3 deletions

View File

@ -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)]">
По этому поиску ничего не найдено.

View File

@ -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} />;
}

View File

@ -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("");
});
});

View File

@ -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>
);
};

View File

@ -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>
)}

View File

@ -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 = {

View File

@ -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");
});
});

View File

@ -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;
};

View File

@ -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);
});
});