From c58f2b06293aa35e6bdec345611a4b86dea4470d Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 17 Sep 2026 12:32:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20SMS=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20(=D0=BE=D1=82=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B1=D0=B5=D0=B7=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B),?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=20=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=B0=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=D1=85;=20feat(autocall):?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20=C2=AB=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D0=BB=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=8C=C2=BB=20/=20=C2=AB=D0=BD=D0=B5=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BB=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=C2=BB?= =?UTF-8?q?=20/=20=C2=AB=D0=B8=20=D1=82=D0=BE=20=D0=B8=20=D0=B4=D1=80?= =?UTF-8?q?=D1=83=D0=B3=D0=BE=D0=B5=C2=BB=20(=D0=91=D0=94=20+=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B9=D0=B4=D0=B6=20+=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ats-diffs-for-pasha.md | 43 +++++++++++++++ docs/sql/autocall-status-split-no-input.sql | 55 +++++++++++++++++++ src/components/UI/CallStatusBadge.jsx | 8 ++- src/components/UI/CallStatusBadge.test.jsx | 16 +++++- src/components/admin/SmsCampaignPanel.jsx | 17 ++++-- src/components/logistics/SmsProblemNotice.jsx | 17 +----- src/services/orderGroupViews.js | 41 +++++++++++--- src/services/smsBalance.js | 43 +++++++++++++++ src/services/smsProblemSummary.js | 11 +++- src/services/smsProblemSummary.test.js | 13 +++++ 10 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 docs/sql/autocall-status-split-no-input.sql create mode 100644 src/services/smsBalance.js diff --git a/docs/ats-diffs-for-pasha.md b/docs/ats-diffs-for-pasha.md index b905e3c..3a40637 100644 --- a/docs/ats-diffs-for-pasha.md +++ b/docs/ats-diffs-for-pasha.md @@ -68,6 +68,49 @@ load = res_agi.so ; Это чинит ночной reboot 03:33: сейчас autoload=no, pbx_spool грузится ; вручную и после ребута слетает — автодозвон молча умирает. +===================================================================== +## ПРАВКА 4 — Запись разговоров автодозвона (MixMonitor) +===================================================================== +Зачем: в карточке заказа нужно послушать звонок робота — слышно, что клиент +говорит и нажимает ли кнопки. Плюс это основа для распознавания голосового +ответа (клиент называет день вместо кнопки). + +Файл: /etc/asterisk/extensions.conf — в контекст [SS-Autocall-Slot] + +В контексте УЖЕ есть готовый [sub-monitor] (папка по дате + mp3 через lame). +Вызываем его первым делом и останавливаем запись на завершении: + +[SS-Autocall-Slot] +; Запись разговора: аргументы (1)=метка(order group), (2)=номер, (3)=тип +exten => s,1,GoSub(sub-monitor,s,1(${GROUP_ID},${CUSTOMER_PHONE},autocall)) +; ВАЖНО: в [sub-monitor] MixMonitor включается с опцией «b» (писать только после +; соединения с абонентом). Для робота абонента-человека нет — файл получится пустым, +; поэтому при вызове из автодозвона нужен вариант без «b». Если оставлять общий +; [sub-monitor], просим убрать «b» только для этого вызова (например, отдельным +; [sub-monitor-robot] с той же логикой, но MixMonitor(${WAV}.wav,,${monopt})). + same => n,NoOp(SS-Autocall-Slot: scenario=${SCENARIO} group=${GROUP_ID} attempt=${ATTEMPT}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(ss-dialog.agi,${SCENARIO},${CALLTYPE}) + same => n,Hangup() +exten => h,1,StopMixMonitor() + +Файл: /etc/asterisk/extensions.conf — альтернатива без правки [sub-monitor] +(если Паша не хочет трогать общий контекст): + +[SS-Autocall-Slot] + same => n,Set(FNAME=${STRFTIME(${EPOCH},,%Y-%m-%d_%H-%M-%S)}_${CUSTOMER_PHONE}_${GROUP_ID}) + same => n,Set(RECORDDIR=/var/spool/asterisk/monitor/${STRFTIME(${EPOCH},,%Y/%m/%d)}) + same => n,Set(MONOPT=nice -n 19 mkdir -p ${RECORDDIR} && chmod 777 ${RECORDDIR} && /usr/bin/lame -b 32 --silent "${RECORDDIR}/${FNAME}.wav" "${RECORDDIR}/${FNAME}.mp3" && rm -f "${RECORDDIR}/${FNAME}.wav" && chmod o+r "${RECORDDIR}/${FNAME}.mp3") + same => n,MixMonitor(${RECORDDIR}/${FNAME}.wav,,${MONOPT}) + ...далее Answer/Wait/AGI как сейчас... +exten => h,1,StopMixMonitor() + +Место: ~1.5 МБ на 2 минуты при 32 кбит/с; на диске 50 ГБ, занято 26 ГБ. +Разбор и выгрузку записей в карточку заказа делаем мы (по расписанию, вне АТС). + +; --- конец правки 4 --- + ===================================================================== ## ПРОВЕРКА ПОСЛЕ ПРИМЕНЕНИЯ (на АТС) ===================================================================== diff --git a/docs/sql/autocall-status-split-no-input.sql b/docs/sql/autocall-status-split-no-input.sql new file mode 100644 index 0000000..ee9b89c --- /dev/null +++ b/docs/sql/autocall-status-split-no-input.sql @@ -0,0 +1,55 @@ +-- Разделяем «не выбрал день/время» на три честных состояния: +-- no_input_day — ответил, день не выбрал +-- no_input_half — день выбрал, время не успел +-- no_input_both — в разных звонках не выбрал и день, и время +-- Значения совпадают с AUTOCALL_STATUS_META / AUTOCALL_STATUS_OPTIONS во фронте +-- (правило: функция в БД + бейдж + фильтр меняются вместе). +CREATE OR REPLACE FUNCTION public.autocall_status(og order_groups) + RETURNS text + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'public' +AS $function$ + select case + when og.autocall_opt_out then 'opt_out' + when exists ( + select 1 from public.autocall_exclusions e + where e.active + and ( + (e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value) + or + (e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%') + ) + ) then 'excluded' + when not exists (select 1 from public.autocall_log l where l.order_group_id = og.id) then 'not_called' + when exists ( + select 1 from public.autocall_log l + where l.order_group_id = og.id + and l.outcome in ('confirmed','confirmed_silent','confirmed_kept_previous') + ) then 'confirmed' + else ( + select case + when l.outcome = 'no_answer' then 'no_answer' + when l.outcome in ('no_input_day','no_input_half') then + case + when exists (select 1 from public.autocall_log l2 + where l2.order_group_id = og.id and l2.outcome = 'no_input_day') + and exists (select 1 from public.autocall_log l3 + where l3.order_group_id = og.id and l3.outcome = 'no_input_half') + then 'no_input_both' + when l.outcome = 'no_input_half' then 'no_input_half' + else 'no_input_day' + end + when l.outcome in ('operator','error','invalid_number') then 'manual' + else 'not_called' + end + from public.autocall_log l + where l.order_group_id = og.id + order by l.created_at desc + limit 1 + ) + end; +$function$; + +-- Проверка: сколько заказов в каждом состоянии +-- select public.autocall_status(og), count(*) from public.order_groups og group by 1 order by 2 desc; diff --git a/src/components/UI/CallStatusBadge.jsx b/src/components/UI/CallStatusBadge.jsx index 929c2ab..ba4f6b4 100644 --- a/src/components/UI/CallStatusBadge.jsx +++ b/src/components/UI/CallStatusBadge.jsx @@ -21,7 +21,9 @@ const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation const STYLES = { confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]", no_answer: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", - no_input: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", + no_input_day: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", + no_input_half: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", + no_input_both: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", manual: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", @@ -31,7 +33,9 @@ const STYLES = { const TITLES = { confirmed: "Клиент согласовал день и половину дня по звонку робота", no_answer: "Робот звонил, трубку не взяли", - no_input: "Трубку взяли, но день/время клиент не выбрал", + no_input_day: "Трубку взяли, но день клиент не выбрал", + no_input_half: "День клиент выбрал, а время — нет", + no_input_both: "В разных звонках клиент не выбрал ни день, ни время", manual: "Нужен менеджер: неверный номер или ошибка звонка", opt_out: "Звонки по этому клиенту запрещены вручную", excluded: "Исключение: по этому номеру или городу не звоним", diff --git a/src/components/UI/CallStatusBadge.test.jsx b/src/components/UI/CallStatusBadge.test.jsx index 18e3da1..0b6665d 100644 --- a/src/components/UI/CallStatusBadge.test.jsx +++ b/src/components/UI/CallStatusBadge.test.jsx @@ -26,14 +26,24 @@ describe("resolveCallBadge — что видно в строке", () => { }); it("у каждого исхода своя подпись, а не одна общая «Автодозвон ❌»", () => { - const labels = ["confirmed", "no_answer", "no_input", "manual"].map( + const labels = ["confirmed", "no_answer", "no_input_day", "no_input_half", "no_input_both", "manual"].map( (status) => resolveCallBadge(group({ autocallStatus: status })).label, ); expect(new Set(labels).size).toBe(labels.length); expect(labels[0]).toContain("согласовано"); expect(labels[1]).toContain("не дозвонились"); - expect(labels[2]).toContain("не выбрал день/время"); - expect(labels[3]).toContain("нужен менеджер"); + expect(labels[2]).toContain("не выбрал день"); + expect(labels[3]).toContain("не выбрал время"); + expect(labels[4]).toContain("не выбрал день и время"); + expect(labels[5]).toContain("нужен менеджер"); + }); + + it("«не выбрал день» и «не выбрал время» — разные подписи, а не одна общая", () => { + const day = resolveCallBadge(group({ autocallStatus: "no_input_day" })); + const half = resolveCallBadge(group({ autocallStatus: "no_input_half" })); + expect(day.label).not.toBe(half.label); + expect(day.title).toContain("день клиент не выбрал"); + expect(half.title).toContain("День клиент выбрал, а время — нет"); }); it("запрет звонков и исключение видны даже в завершённом заказе", () => { diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index 51fbf61..e55163b 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -10,6 +10,7 @@ import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; import { SmsCampaignStats } from "./SmsCampaignStats"; import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel"; +import { resolveSmsBalance } from "../../services/smsBalance"; // ── Campaigns ─────────────────────────────────────────────────────────────── const CAMPAIGNS = [ @@ -114,6 +115,7 @@ export const SmsCampaignPanel = () => { const [settings, setSettings] = useState(null); const [allSettings, setAllSettings] = useState({}); const [campaignCounts, setCampaignCounts] = useState({}); + const [smsBalance, setSmsBalance] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState("all"); @@ -145,6 +147,9 @@ export const SmsCampaignPanel = () => { (allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; }); setAllSettings(settingsMap); + // Баланс — тот же живой источник, что на главной странице + resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal)); + // Load counts per campaign (for cards) const { data: countsData, error: countsErr } = await supabase .from("sms_campaign_log") @@ -317,17 +322,21 @@ export const SmsCampaignPanel = () => { return (
- {/* ── Balance + Campaign cards ─────────────────────────────────────────── */} - {/* Balance banner */} - {allSettings.first_sms?.last_balance != null && ( + {/* Balance banner — тот же живой баланс, что на главной (один источник) */} + {smsBalance?.balance != null && (
💰
Баланс sms.ru
- {allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽ + {smsBalance.balance.toLocaleString("ru-RU")} ₽
+ {!smsBalance.live && ( +
+ показан баланс на момент последней рассылки — живой запрос не прошёл +
+ )}
diff --git a/src/components/logistics/SmsProblemNotice.jsx b/src/components/logistics/SmsProblemNotice.jsx index 0b67f77..a0b82e7 100644 --- a/src/components/logistics/SmsProblemNotice.jsx +++ b/src/components/logistics/SmsProblemNotice.jsx @@ -16,6 +16,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { supabase } from "../../supabaseClient"; import { summarizeSmsFailures } from "../../services/smsProblemSummary"; +import { fetchSmsBalance } from "../../services/smsBalance"; import { SmsProblemNoticeView } from "./SmsProblemNoticeView"; const LOOKBACK_DAYS = 30; @@ -41,20 +42,8 @@ const writeDismissed = (signature) => { /** Баланс счёта 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; - } + const live = await fetchSmsBalance(); + return live ? { balance: live.balance, lowBalance: live.lowBalance } : null; }; export function SmsProblemNotice({ orderGroupIds = [], className = "" }) { diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index 1347765..fe58e5f 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -360,13 +360,15 @@ export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CO // набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся. // Это единственный источник подписей: их берут и бейдж в таблице, и фильтры. export const AUTOCALL_STATUS_META = { - not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" }, - confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" }, - no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" }, - no_input: { label: "Не выбрал день/время", short: "не выбрал день/время", tone: "warning", icon: "🤐" }, - manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" }, - opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" }, - excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" }, + not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" }, + confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" }, + no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" }, + no_input_day: { label: "Не выбрал день", short: "не выбрал день", tone: "warning", icon: "📅" }, + no_input_half: { label: "Не выбрал время", short: "не выбрал время", tone: "warning", icon: "🕐" }, + no_input_both: { label: "Не выбрал день и время", short: "не выбрал день и время", tone: "warning", icon: "🤐" }, + manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" }, + opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" }, + excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" }, }; /** Порядок для выпадающих списков — один список на всё приложение. */ @@ -374,12 +376,31 @@ export const AUTOCALL_STATUS_OPTIONS = [ { value: "not_called", label: "Не звонили" }, { value: "confirmed", label: "Согласовано" }, { value: "no_answer", label: "Не дозвонились" }, - { value: "no_input", label: "Не выбрал день/время" }, + { value: "no_input_day", label: "Не выбрал день" }, + { value: "no_input_half", label: "Не выбрал время" }, + { value: "no_input_both", label: "Не выбрал день и время" }, { value: "manual", label: "Нужен менеджер" }, { value: "opt_out", label: "Не звонить (запрет вручную)" }, { value: "excluded", label: "Не звоним (исключение)" }, ]; +/** + * Что именно клиент не выбрал: день, время или и то и другое. + * + * Считаем по всем звонкам заказа, а не только по последнему: если в одном + * звонке клиент не выбрал день, а в другом день выбрал, но не успел время — + * честнее сказать «не выбрал день и время», чем показывать половину картины. + */ +export const resolveNoInputKind = (calls = []) => { + const outcomes = new Set((calls || []).map((c) => c?.outcome)); + const day = outcomes.has("no_input_day"); + const half = outcomes.has("no_input_half"); + if (day && half) return "no_input_both"; + if (half) return "no_input_half"; + if (day) return "no_input_day"; + return null; +}; + export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => { if (optOut) return "opt_out"; if (exclusionReason) return "excluded"; @@ -387,7 +408,9 @@ export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionRea const last = calls[0]; if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed"; if (last.outcome === "no_answer") return "no_answer"; - if (["no_input_day", "no_input_half"].includes(last.outcome)) return "no_input"; + if (["no_input_day", "no_input_half"].includes(last.outcome)) { + return resolveNoInputKind(calls) || "no_input_day"; + } if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual"; return "not_called"; }; diff --git a/src/services/smsBalance.js b/src/services/smsBalance.js new file mode 100644 index 0000000..b91132e --- /dev/null +++ b/src/services/smsBalance.js @@ -0,0 +1,43 @@ +/** + * @file smsBalance.js + * @description Баланс счёта SMS-сервиса — один источник на всё приложение. + * + * Раньше главная страница брала баланс живым запросом (функция sms-balance), + * а страница «Коммуникации» — сохранённое значение sms_campaign_settings.last_balance, + * которое обновляется только во время рассылки. Из-за этого две страницы показывали + * разные суммы. Теперь обе спрашивают одно и то же — живой баланс; сохранённое + * значение остаётся только запасным вариантом, если запрос не прошёл. + */ + +const BALANCE_LOW_RUB = 100; + +/** Живой баланс sms.ru через edge-функцию (ключ сервиса живёт только на сервере). */ +export const fetchSmsBalance = 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(); + if (!payload?.ok) return null; + return { balance: payload.balance, lowBalance: payload.threshold ?? BALANCE_LOW_RUB, live: true }; + } catch { + return null; + } +}; + +/** Живой баланс, а если он недоступен — сохранённый (и пометка, что он не свежий). */ +export const resolveSmsBalance = async (fallbackBalance = null) => { + const live = await fetchSmsBalance(); + if (live) return live; + if (Number.isFinite(fallbackBalance)) { + return { balance: fallbackBalance, lowBalance: BALANCE_LOW_RUB, live: false }; + } + return null; +}; + +export const SMS_LOW_BALANCE_RUB = BALANCE_LOW_RUB; diff --git a/src/services/smsProblemSummary.js b/src/services/smsProblemSummary.js index c4a7fc9..2a76b0d 100644 --- a/src/services/smsProblemSummary.js +++ b/src/services/smsProblemSummary.js @@ -9,8 +9,10 @@ */ import { getSmsFailureReason } from "./orderGroupViews"; -/** Через сколько дней после последней ошибки показываем, что всё наладилось. */ -export const RESOLVED_VISIBLE_DAYS = 7; +/** Через сколько дней после последней ошибки показываем, что всё наладилось. + * Короткий срок намеренно: «SMS снова уходят» — сообщение на день-два, а не + * постоянный баннер на главной (владелец закрывал его, а он возвращался). */ +export const RESOLVED_VISIBLE_DAYS = 2; /** * @param {Array} rows строки sms_campaign_log со статусами ошибок @@ -87,7 +89,10 @@ export const summarizeSmsFailures = (rows = [], visibleIds = [], options = {}) = return { ...base, kind, - signature: [kind, reason || "", lastAt || "", balanceKnown ? Math.round(balance) : ""].join("|"), + // Отпечаток — только то, что делает сообщение НОВЫМ: вид проблемы, причина и + // время последней ошибки. Баланс сюда НЕ входит: он меняется от каждой отправки, + // из-за него закрытый баннер возвращался на следующий день. + signature: [kind, reason || "", lastAt || ""].join("|"), }; }; diff --git a/src/services/smsProblemSummary.test.js b/src/services/smsProblemSummary.test.js index d6d9f11..27dac68 100644 --- a/src/services/smsProblemSummary.test.js +++ b/src/services/smsProblemSummary.test.js @@ -72,6 +72,19 @@ describe("summarizeSmsFailures — актуальность по балансу" expect(summary).toBeNull(); }); + it("«всё наладилось» живёт день-два и гаснет само", () => { + const fresh = summarizeSmsFailures([row({ created_at: daysAgo(0.5) })], [], { balance: 12717.45 }); + expect(fresh.kind).toBe("resolved"); + const stale = summarizeSmsFailures([row({ created_at: daysAgo(3) })], [], { balance: 12717.45 }); + expect(stale).toBeNull(); + }); + + it("отпечаток не зависит от суммы на счёте — закрытый баннер не возвращается", () => { + const a = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 12717.45 }); + const b = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 11122.69 }); + expect(a.signature).toBe(b.signature); + }); + it("ошибок нет, но денег мало — предупреждаем заранее", () => { const summary = summarizeSmsFailures([], [], { balance: 55 }); expect(summary.kind).toBe("low_balance");