feat(orders): блок про SMS смотрит на баланс счёта — гасится после пополнения, закрывается крестиком, предупреждает о низком балансе (эдж-функция sms-balance)
This commit is contained in:
parent
dc262d2be7
commit
3706f64ca7
|
|
@ -1,24 +1,65 @@
|
|||
/**
|
||||
* @file SmsProblemNotice.jsx
|
||||
* @description Объясняет причину, по которой SMS не уходят.
|
||||
* @description Объясняет, что происходит с отправкой SMS — над таблицей наборов.
|
||||
*
|
||||
* В таблице наборов видно только «SMS не отправлено», а причина лежит в логе
|
||||
* отправок цифрой (например, 201 — на счёте SMS-сервиса закончились средства).
|
||||
* Блок читает последние неудачные отправки, складывает их по причинам
|
||||
* (summarizeSmsFailures) и показывает причину словами над таблицей.
|
||||
* В таблице видно только «SMS не отправлено», а причина лежит в логе отправок
|
||||
* цифрой (например, 201 — на счёте SMS-сервиса закончились средства). Блок
|
||||
* читает последние неудачные отправки, спрашивает баланс счёта через функцию
|
||||
* sms-balance и показывает вывод словами:
|
||||
* • средств нет — красное предупреждение с причиной;
|
||||
* • счёт пополнен — короткая строка «SMS снова уходят» (гаснет сама через неделю);
|
||||
* • денег мало — предупреждение заранее, до остановки отправки.
|
||||
*
|
||||
* Если неудачных отправок нет — блок не показывается вообще.
|
||||
* Крестик закрывает блок: решение запоминается в браузере по «отпечатку»
|
||||
* состояния, поэтому та же самая проблема больше не всплывает, а новая — показывается.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, 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"];
|
||||
const BALANCE_LOW_RUB = 100;
|
||||
const DISMISS_STORAGE_KEY = "supersam_sms_problem_dismissed";
|
||||
|
||||
const readDismissed = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(DISMISS_STORAGE_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const writeDismissed = (signature) => {
|
||||
try {
|
||||
window.localStorage.setItem(DISMISS_STORAGE_KEY, signature);
|
||||
} catch {
|
||||
// Приватный режим — просто не запоминаем.
|
||||
}
|
||||
};
|
||||
|
||||
/** Баланс счёта SMS-сервиса: ключ живёт только на сервере, поэтому спрашиваем функцию. */
|
||||
const fetchBalance = async () => {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL || "";
|
||||
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
||||
if (!url) return null;
|
||||
try {
|
||||
const resp = await fetch(`${url}/functions/v1/sms-balance`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(anonKey ? { apikey: anonKey } : {}) },
|
||||
body: "{}",
|
||||
});
|
||||
const payload = await resp.json();
|
||||
return payload?.ok ? { balance: payload.balance, lowBalance: payload.threshold ?? BALANCE_LOW_RUB } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function SmsProblemNotice({ orderGroupIds = [], className = "" }) {
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [dismissed, setDismissed] = useState(readDismissed);
|
||||
const visibleKey = useMemo(() => orderGroupIds.join(","), [orderGroupIds]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -26,15 +67,22 @@ export function SmsProblemNotice({ orderGroupIds = [], className = "" }) {
|
|||
(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);
|
||||
const [{ data, error }, balanceInfo] = await Promise.all([
|
||||
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),
|
||||
fetchBalance(),
|
||||
]);
|
||||
if (error) throw error;
|
||||
if (alive) setSummary(summarizeSmsFailures(data || [], orderGroupIds));
|
||||
if (!alive) return;
|
||||
setSummary(summarizeSmsFailures(data || [], orderGroupIds, {
|
||||
balance: balanceInfo?.balance ?? null,
|
||||
lowBalance: balanceInfo?.lowBalance ?? BALANCE_LOW_RUB,
|
||||
}));
|
||||
} catch {
|
||||
// Лог может быть недоступен для роли — тогда просто ничего не показываем.
|
||||
if (alive) setSummary(null);
|
||||
|
|
@ -45,5 +93,13 @@ export function SmsProblemNotice({ orderGroupIds = [], className = "" }) {
|
|||
};
|
||||
}, [visibleKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <SmsProblemNoticeView summary={summary} className={className} />;
|
||||
const handleClose = useCallback(() => {
|
||||
if (!summary) return;
|
||||
writeDismissed(summary.signature);
|
||||
setDismissed(summary.signature);
|
||||
}, [summary]);
|
||||
|
||||
const visible = summary && summary.signature !== dismissed ? summary : null;
|
||||
|
||||
return <SmsProblemNoticeView summary={visible} onClose={handleClose} className={className} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
/**
|
||||
* @file SmsProblemNotice.test.jsx
|
||||
* @description Блок над таблицей: причина «закончились средства» написана словами,
|
||||
* видно период и число заказов; без причины блок не рисуется.
|
||||
* @description Блок про SMS: причина словами, «всё наладилось», предупреждение о балансе
|
||||
* и крестик, которым блок можно закрыть.
|
||||
*/
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
|
||||
|
||||
const fundsSummary = {
|
||||
/** В русской локали разделитель разрядов — неразрывный пробел. */
|
||||
const plain = (html) => String(html).replace(/[\u00a0\u202f]/g, " ");
|
||||
|
||||
const base = {
|
||||
reason: "на счёте SMS-сервиса закончились средства",
|
||||
messages: 42,
|
||||
topMessages: 42,
|
||||
|
|
@ -18,11 +21,14 @@ const fundsSummary = {
|
|||
hasFundsReason: true,
|
||||
firstAt: "2026-09-10T10:06:11Z",
|
||||
lastAt: "2026-09-13T06:03:03Z",
|
||||
balance: 12717.45,
|
||||
balanceKnown: true,
|
||||
lowBalanceThreshold: 100,
|
||||
};
|
||||
|
||||
describe("SmsProblemNoticeView", () => {
|
||||
it("пишет, что закончились средства, и что делать", () => {
|
||||
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={fundsSummary} />);
|
||||
it("при нехватке средств пишет причину и что делать", () => {
|
||||
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} />);
|
||||
|
||||
expect(html).toContain("SMS не отправлено: на счёте SMS-сервиса закончились средства");
|
||||
expect(html).toContain("8 заказов");
|
||||
|
|
@ -31,28 +37,41 @@ describe("SmsProblemNoticeView", () => {
|
|||
expect(html).toContain("Пополните счёт SMS-сервиса");
|
||||
});
|
||||
|
||||
it("после пополнения счёта красное предупреждение сменяется спокойным «снова уходят»", () => {
|
||||
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "resolved" }} />);
|
||||
|
||||
expect(html).toContain("SMS снова уходят");
|
||||
expect(plain(html)).toContain("на счёте есть деньги (12 717,45 ₽)");
|
||||
expect(html).not.toContain("Пополните счёт");
|
||||
expect(html).toContain("Эти сообщения не ушли с 10.09 по 13.09 из-за нехватки средств");
|
||||
expect(html).not.toContain("В таблице ниже");
|
||||
});
|
||||
|
||||
it("предупреждает о низком балансе, когда ошибок ещё нет", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmsProblemNoticeView summary={{ ...base, kind: "low_balance", reason: null, groups: 0, messages: 0, balance: 55 }} />,
|
||||
);
|
||||
|
||||
expect(plain(html)).toContain("На счёте SMS-сервиса мало денег: 55 ₽");
|
||||
expect(html).toContain("пополните счёт заранее");
|
||||
});
|
||||
|
||||
it("для другой причины подсказка другая", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmsProblemNoticeView
|
||||
summary={{ ...fundsSummary, reason: "неверный номер получателя", hasFundsReason: false, groups: 1 }}
|
||||
/>,
|
||||
<SmsProblemNoticeView summary={{ ...base, kind: "other", 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("крестик есть, когда блок можно закрыть", () => {
|
||||
const withClose = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} onClose={vi.fn()} />);
|
||||
const withoutClose = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} />);
|
||||
|
||||
expect(withClose).toContain("Скрыть сообщение");
|
||||
expect(withoutClose).not.toContain("Скрыть сообщение");
|
||||
});
|
||||
|
||||
it("без сводки ничего не рисуется", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* @file SmsProblemNoticeView.jsx
|
||||
* @description Разметка блока «почему SMS не уходят».
|
||||
* @description Разметка блока про SMS: причина, «всё наладилось» и предупреждение о низком балансе.
|
||||
*
|
||||
* Отдельно от загрузки данных: сам блок только рисует то, что посчитал
|
||||
* summarizeSmsFailures. Так разметку можно проверить без сети и Supabase.
|
||||
* Отдельно от загрузки данных: блок рисует то, что посчитал summarizeSmsFailures.
|
||||
* Так разметку можно проверить без сети и Supabase. Крестик закрывает блок —
|
||||
* состояние запоминает сам блок данных (SmsProblemNotice).
|
||||
*/
|
||||
import React from "react";
|
||||
import { plural } from "../../services/smsProblemSummary";
|
||||
|
|
@ -17,15 +18,48 @@ const fmtDay = (iso) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const SmsProblemNoticeView = ({ summary, className = "" }) => {
|
||||
const fmtMoney = (value) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "";
|
||||
return `${n.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ₽`;
|
||||
};
|
||||
|
||||
const TONES = {
|
||||
funds: {
|
||||
icon: "⛔",
|
||||
box: "border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)]",
|
||||
chip: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
|
||||
text: "text-[var(--color-danger)]",
|
||||
},
|
||||
low_balance: {
|
||||
icon: "⚠️",
|
||||
box: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]",
|
||||
chip: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]",
|
||||
text: "text-[var(--color-warning)]",
|
||||
},
|
||||
other: {
|
||||
icon: "⚠️",
|
||||
box: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]",
|
||||
chip: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]",
|
||||
text: "text-[var(--color-warning)]",
|
||||
},
|
||||
resolved: {
|
||||
icon: "✅",
|
||||
box: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
|
||||
chip: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||
text: "text-[var(--color-text-muted)]",
|
||||
},
|
||||
};
|
||||
|
||||
export const SmsProblemNoticeView = ({ summary, onClose, className = "" }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
const tone = TONES[summary.kind] || TONES.other;
|
||||
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 =
|
||||
|
|
@ -35,43 +69,60 @@ export const SmsProblemNoticeView = ({ summary, className = "" }) => {
|
|||
? "В текущей выборке таблицы таких заказов нет."
|
||||
: "";
|
||||
|
||||
const headline =
|
||||
summary.kind === "funds"
|
||||
? `SMS не отправлено: ${summary.reason}`
|
||||
: summary.kind === "low_balance"
|
||||
? `На счёте SMS-сервиса мало денег: ${fmtMoney(summary.balance)}`
|
||||
: summary.kind === "resolved"
|
||||
? `SMS снова уходят: на счёте есть деньги (${fmtMoney(summary.balance)})`
|
||||
: `SMS не отправлено: ${summary.reason}`;
|
||||
|
||||
const hint =
|
||||
summary.kind === "funds"
|
||||
? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно."
|
||||
: summary.kind === "low_balance"
|
||||
? `Осталось меньше ${fmtMoney(summary.lowBalanceThreshold)} — пополните счёт заранее, иначе отправка остановится.`
|
||||
: summary.kind === "resolved"
|
||||
? `Эти сообщения не ушли ${period} из-за нехватки средств — они остались в разделе «SMS-рассылки». Новые отправки идут нормально, делать ничего не нужно.`
|
||||
: "Проверьте настройки отправки SMS в разделе «SMS-рассылки».";
|
||||
|
||||
// Для «всё наладилось» не подмешиваем счётчик из таблицы — он там уже не важен.
|
||||
const showTableText = summary.kind !== "resolved";
|
||||
|
||||
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 className={["rounded-[28px] border p-4 text-sm", tone.box, className].join(" ")}>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<span className="text-base leading-none">{tone.icon}</span>
|
||||
<span className="min-w-0 flex-1 font-semibold text-[var(--color-text)]">{headline}</span>
|
||||
{summary.kind !== "low_balance" && (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${tone.chip}`}>
|
||||
{groupsText}
|
||||
</span>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Скрыть сообщение"
|
||||
title="Скрыть сообщение"
|
||||
className="-mr-1 -mt-1 rounded-full px-2 py-0.5 text-base leading-none text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{messagesText}
|
||||
{period ? ` · ${period}` : ""}
|
||||
{tableText ? ` · ${tableText}` : ""}
|
||||
</div>
|
||||
{summary.kind !== "low_balance" && (
|
||||
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{messagesText}
|
||||
{period ? ` · ${period}` : ""}
|
||||
{showTableText && tableText ? ` · ${tableText}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-[var(--color-text)]">
|
||||
{summary.hasFundsReason
|
||||
? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно."
|
||||
: "Проверьте настройки отправки SMS в разделе «SMS-рассылки»."}
|
||||
<div className={`mt-2 text-xs ${summary.kind === "resolved" ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}`}>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,19 +3,25 @@
|
|||
* @description Складывает неудачные отправки SMS в одну понятную причину.
|
||||
*
|
||||
* В таблице видно только «SMS не отправлено», а причина в логе — код сервиса.
|
||||
* Здесь считаем: сколько сообщений, сколько заказов, за какой период и что
|
||||
* именно случилось чаще всего (например, что на счёте закончились средства).
|
||||
* Здесь считаем: сколько сообщений, сколько заказов, за какой период, что
|
||||
* случилось чаще всего — и главное, актуальна ли проблема до сих пор (по балансу
|
||||
* счёта SMS-сервиса). Пополнили счёт — предупреждение гасим, а не держим месяцами.
|
||||
*/
|
||||
import { getSmsFailureReason } from "./orderGroupViews";
|
||||
|
||||
/** Через сколько дней после последней ошибки показываем, что всё наладилось. */
|
||||
export const RESOLVED_VISIBLE_DAYS = 7;
|
||||
|
||||
/**
|
||||
* @param {Array} rows строки sms_campaign_log со статусами ошибок
|
||||
* @param {Array<string>} visibleIds id заказов, которые сейчас видны в таблице
|
||||
* @returns {null|{reason, messages, groups, affectedInTable, hasFundsReason, firstAt, lastAt}}
|
||||
* @param {{balance?: number|null, lowBalance?: number}} options баланс счёта SMS-сервиса
|
||||
* @returns {null|object} null — говорить не о чем
|
||||
*/
|
||||
export const summarizeSmsFailures = (rows = [], visibleIds = []) => {
|
||||
export const summarizeSmsFailures = (rows = [], visibleIds = [], options = {}) => {
|
||||
const { balance = null, lowBalance = 100 } = options;
|
||||
const balanceKnown = Number.isFinite(balance);
|
||||
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
||||
if (!list.length) return null;
|
||||
|
||||
const reasons = new Map();
|
||||
const lastByGroup = new Map();
|
||||
|
|
@ -35,19 +41,53 @@ export const summarizeSmsFailures = (rows = [], visibleIds = []) => {
|
|||
}
|
||||
}
|
||||
|
||||
const [reason, topMessages] = [...reasons.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const [reason, topMessages] = list.length
|
||||
? [...reasons.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
: [null, 0];
|
||||
|
||||
const hasFundsReason = !!reason && /закончились средства/.test(reason);
|
||||
const visible = new Set(visibleIds);
|
||||
|
||||
return {
|
||||
// Базовая сводка по логу (нужна в любом состоянии).
|
||||
const base = {
|
||||
reason,
|
||||
messages: list.length,
|
||||
topMessages,
|
||||
messages: list.length,
|
||||
groups: lastByGroup.size,
|
||||
affectedInTable: [...lastByGroup.keys()].filter((id) => visible.has(id)).length,
|
||||
hasVisibleRows: visible.size > 0,
|
||||
hasFundsReason: /закончились средства/.test(reason),
|
||||
hasFundsReason,
|
||||
firstAt,
|
||||
lastAt,
|
||||
balance,
|
||||
balanceKnown,
|
||||
lowBalanceThreshold: lowBalance,
|
||||
};
|
||||
|
||||
const within = (days) => {
|
||||
if (!lastAt) return false;
|
||||
return Date.now() - new Date(lastAt).getTime() <= days * 86400000;
|
||||
};
|
||||
|
||||
let kind = null;
|
||||
if (!list.length) {
|
||||
// Ошибок нет, но денег на счёте мало — предупреждаем заранее.
|
||||
kind = balanceKnown && balance < lowBalance ? "low_balance" : null;
|
||||
} else if (hasFundsReason && balanceKnown && balance >= lowBalance) {
|
||||
// Деньги пополнены — проблема снята, показываем это недолго и без нагнетания.
|
||||
kind = within(RESOLVED_VISIBLE_DAYS) ? "resolved" : null;
|
||||
} else if (hasFundsReason) {
|
||||
kind = "funds";
|
||||
} else {
|
||||
kind = "other";
|
||||
}
|
||||
|
||||
if (!kind) return null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
signature: [kind, reason || "", lastAt || "", balanceKnown ? Math.round(balance) : ""].join("|"),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* @file smsProblemSummary.test.js
|
||||
* @description Причина неудачных SMS для блока над таблицей: считаем по логу,
|
||||
* чаще всего встречающуюся причину пишем словами, пустой лог — ничего не показываем.
|
||||
* @description Что показывать над таблицей: причина недоставки, «всё наладилось»,
|
||||
* предупреждение о низком балансе — и когда не показывать ничего.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { summarizeSmsFailures } from "./smsProblemSummary";
|
||||
|
|
@ -16,7 +16,9 @@ const row = (over = {}) => ({
|
|||
...over,
|
||||
});
|
||||
|
||||
describe("summarizeSmsFailures", () => {
|
||||
const daysAgo = (days) => new Date(Date.now() - days * 86400000).toISOString();
|
||||
|
||||
describe("summarizeSmsFailures — причина недоставки", () => {
|
||||
it("объясняет нехватку средств и считает заказы с сообщениями", () => {
|
||||
const summary = summarizeSmsFailures([
|
||||
row({ order_group_id: "g1", created_at: "2026-09-10T10:06:11Z" }),
|
||||
|
|
@ -24,19 +26,11 @@ describe("summarizeSmsFailures", () => {
|
|||
row({ order_group_id: "g2", created_at: "2026-09-13T06:03:04Z" }),
|
||||
], ["g1", "g2"]);
|
||||
|
||||
expect(summary.kind).toBe("funds");
|
||||
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("берёт самую частую причину, когда их несколько", () => {
|
||||
|
|
@ -50,15 +44,60 @@ describe("summarizeSmsFailures", () => {
|
|||
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);
|
||||
it("другая причина — отдельный вид блока", () => {
|
||||
const summary = summarizeSmsFailures([row({ sms_code: "202", error_message: "202" })], []);
|
||||
expect(summary.kind).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeSmsFailures — актуальность по балансу", () => {
|
||||
it("счёт пополнен: вместо красного предупреждения — «всё наладилось»", () => {
|
||||
const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 12717.45 });
|
||||
expect(summary.kind).toBe("resolved");
|
||||
expect(summary.balance).toBe(12717.45);
|
||||
});
|
||||
|
||||
it("денег на счёте нет: проблема актуальна", () => {
|
||||
const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
|
||||
expect(summary.kind).toBe("funds");
|
||||
});
|
||||
|
||||
it("баланс узнать не удалось — считаем проблему актуальной", () => {
|
||||
const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: null });
|
||||
expect(summary.kind).toBe("funds");
|
||||
});
|
||||
|
||||
it("старая история не висит месяцами: неделя прошла — не показываем", () => {
|
||||
const summary = summarizeSmsFailures([row({ created_at: daysAgo(8) })], [], { balance: 12717.45 });
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it("ошибок нет, но денег мало — предупреждаем заранее", () => {
|
||||
const summary = summarizeSmsFailures([], [], { balance: 55 });
|
||||
expect(summary.kind).toBe("low_balance");
|
||||
expect(summary.balance).toBe(55);
|
||||
expect(summary.reason).toBeNull();
|
||||
});
|
||||
|
||||
it("ошибок нет и денег достаточно — блок не нужен", () => {
|
||||
expect(summarizeSmsFailures([], [], { balance: 12717.45 })).toBeNull();
|
||||
expect(summarizeSmsFailures(null, [], { balance: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("отпечаток состояния для закрытия блока", () => {
|
||||
it("меняется, когда проблема другая — закрытый блок вернётся с новой ошибкой", () => {
|
||||
const first = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
|
||||
const second = summarizeSmsFailures([row({ created_at: daysAgo(2) })], [], { balance: 42 });
|
||||
const third = summarizeSmsFailures([row({ created_at: daysAgo(1), sms_code: "202", error_message: "202" })], [], { balance: 42 });
|
||||
|
||||
expect(first.signature).not.toBe(second.signature);
|
||||
expect(first.signature).not.toBe(third.signature);
|
||||
});
|
||||
|
||||
it("одинаковые данные — одинаковый отпечаток (повторно не всплывает)", () => {
|
||||
const a = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
|
||||
const b = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
|
||||
expect(a.signature).toBe(b.signature);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
/** Баланс счёта SMS-сервиса — для предупреждения в интерфейсе.
|
||||
*
|
||||
* Зачем: 10–13 сентября рассылки падали с кодом 201 («не хватает средств»),
|
||||
* и об этом никто не знал. Ключ доступа к SMS живёт только на сервере, поэтому
|
||||
* баланс отдаём отдельной функцией, а не читаем ключ в браузере.
|
||||
*/
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://dost.supersamsev.ru",
|
||||
"https://supa.supersamsev.ru",
|
||||
"https://dev.mkn8n.ru",
|
||||
"http://localhost:5173",
|
||||
];
|
||||
|
||||
const BALANCE_URL = "https://sms.ru/my/balance";
|
||||
// Ниже этого остатка предупреждаем заранее, до остановки отправки.
|
||||
const LOW_BALANCE_RUB = 100;
|
||||
|
||||
const cors = (origin: string) => ({
|
||||
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
|
||||
"Access-Control-Allow-Methods": "POST,GET,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
|
||||
});
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const origin = req.headers.get("origin") || "";
|
||||
const headers = { ...cors(origin), "Content-Type": "application/json" };
|
||||
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers });
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
||||
const sb = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const { data: settings } = await sb
|
||||
.from("sms_campaign_settings")
|
||||
.select("sms_api_id")
|
||||
.eq("campaign_type", "first_sms")
|
||||
.single();
|
||||
|
||||
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
|
||||
if (!apiId) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "api_id not configured" }), { status: 500, headers });
|
||||
}
|
||||
|
||||
const resp = await fetch(`${BALANCE_URL}?api_id=${encodeURIComponent(apiId)}&json=1`);
|
||||
const text = await resp.text();
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ ok: false, error: "unexpected response", raw: text.slice(0, 200) }), { status: 200, headers });
|
||||
}
|
||||
|
||||
if (payload.status !== "OK") {
|
||||
return new Response(JSON.stringify({ ok: false, code: payload.status_code ?? null, raw: text.slice(0, 200) }), { status: 200, headers });
|
||||
}
|
||||
|
||||
const balance = Number(payload.balance);
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
balance,
|
||||
low: balance < LOW_BALANCE_RUB,
|
||||
threshold: LOW_BALANCE_RUB,
|
||||
}), { headers });
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({ ok: false, error: String(e) }), { status: 500, headers });
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue