fix(ui): закрытый баннер SMS больше не возвращается (отпечаток без суммы), баланс один на всех страницах; feat(autocall): статусы «не выбрал день» / «не выбрал время» / «и то и другое» (БД + бейдж + фильтр)
This commit is contained in:
parent
775a72ea29
commit
c58f2b0629
|
|
@ -68,6 +68,49 @@ load = res_agi.so
|
||||||
; Это чинит ночной reboot 03:33: сейчас autoload=no, pbx_spool грузится
|
; Это чинит ночной 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 ---
|
||||||
|
|
||||||
=====================================================================
|
=====================================================================
|
||||||
## ПРОВЕРКА ПОСЛЕ ПРИМЕНЕНИЯ (на АТС)
|
## ПРОВЕРКА ПОСЛЕ ПРИМЕНЕНИЯ (на АТС)
|
||||||
=====================================================================
|
=====================================================================
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -21,7 +21,9 @@ const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation
|
||||||
const STYLES = {
|
const STYLES = {
|
||||||
confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]",
|
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_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)]",
|
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)]",
|
opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
|
||||||
excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||||
|
|
@ -31,7 +33,9 @@ const STYLES = {
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
confirmed: "Клиент согласовал день и половину дня по звонку робота",
|
confirmed: "Клиент согласовал день и половину дня по звонку робота",
|
||||||
no_answer: "Робот звонил, трубку не взяли",
|
no_answer: "Робот звонил, трубку не взяли",
|
||||||
no_input: "Трубку взяли, но день/время клиент не выбрал",
|
no_input_day: "Трубку взяли, но день клиент не выбрал",
|
||||||
|
no_input_half: "День клиент выбрал, а время — нет",
|
||||||
|
no_input_both: "В разных звонках клиент не выбрал ни день, ни время",
|
||||||
manual: "Нужен менеджер: неверный номер или ошибка звонка",
|
manual: "Нужен менеджер: неверный номер или ошибка звонка",
|
||||||
opt_out: "Звонки по этому клиенту запрещены вручную",
|
opt_out: "Звонки по этому клиенту запрещены вручную",
|
||||||
excluded: "Исключение: по этому номеру или городу не звоним",
|
excluded: "Исключение: по этому номеру или городу не звоним",
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,24 @@ describe("resolveCallBadge — что видно в строке", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("у каждого исхода своя подпись, а не одна общая «Автодозвон ❌»", () => {
|
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,
|
(status) => resolveCallBadge(group({ autocallStatus: status })).label,
|
||||||
);
|
);
|
||||||
expect(new Set(labels).size).toBe(labels.length);
|
expect(new Set(labels).size).toBe(labels.length);
|
||||||
expect(labels[0]).toContain("согласовано");
|
expect(labels[0]).toContain("согласовано");
|
||||||
expect(labels[1]).toContain("не дозвонились");
|
expect(labels[1]).toContain("не дозвонились");
|
||||||
expect(labels[2]).toContain("не выбрал день/время");
|
expect(labels[2]).toContain("не выбрал день");
|
||||||
expect(labels[3]).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("запрет звонков и исключение видны даже в завершённом заказе", () => {
|
it("запрет звонков и исключение видны даже в завершённом заказе", () => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||||
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
||||||
|
import { resolveSmsBalance } from "../../services/smsBalance";
|
||||||
|
|
||||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -114,6 +115,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const [settings, setSettings] = useState(null);
|
const [settings, setSettings] = useState(null);
|
||||||
const [allSettings, setAllSettings] = useState({});
|
const [allSettings, setAllSettings] = useState({});
|
||||||
const [campaignCounts, setCampaignCounts] = useState({});
|
const [campaignCounts, setCampaignCounts] = useState({});
|
||||||
|
const [smsBalance, setSmsBalance] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
|
|
@ -145,6 +147,9 @@ export const SmsCampaignPanel = () => {
|
||||||
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
||||||
setAllSettings(settingsMap);
|
setAllSettings(settingsMap);
|
||||||
|
|
||||||
|
// Баланс — тот же живой источник, что на главной странице
|
||||||
|
resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal));
|
||||||
|
|
||||||
// Load counts per campaign (for cards)
|
// Load counts per campaign (for cards)
|
||||||
const { data: countsData, error: countsErr } = await supabase
|
const { data: countsData, error: countsErr } = await supabase
|
||||||
.from("sms_campaign_log")
|
.from("sms_campaign_log")
|
||||||
|
|
@ -317,17 +322,21 @@ export const SmsCampaignPanel = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
{/* Balance banner — тот же живой баланс, что на главной (один источник) */}
|
||||||
{/* Balance banner */}
|
{smsBalance?.balance != null && (
|
||||||
{allSettings.first_sms?.last_balance != null && (
|
|
||||||
<Panel className="p-3">
|
<Panel className="p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">💰</span>
|
<span className="text-2xl">💰</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||||||
<div className="text-lg font-bold text-[var(--color-text)]">
|
<div className="text-lg font-bold text-[var(--color-text)]">
|
||||||
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
{smsBalance.balance.toLocaleString("ru-RU")} ₽
|
||||||
</div>
|
</div>
|
||||||
|
{!smsBalance.live && (
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
показан баланс на момент последней рассылки — живой запрос не прошёл
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { summarizeSmsFailures } from "../../services/smsProblemSummary";
|
import { summarizeSmsFailures } from "../../services/smsProblemSummary";
|
||||||
|
import { fetchSmsBalance } from "../../services/smsBalance";
|
||||||
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
|
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
|
||||||
|
|
||||||
const LOOKBACK_DAYS = 30;
|
const LOOKBACK_DAYS = 30;
|
||||||
|
|
@ -41,20 +42,8 @@ const writeDismissed = (signature) => {
|
||||||
|
|
||||||
/** Баланс счёта SMS-сервиса: ключ живёт только на сервере, поэтому спрашиваем функцию. */
|
/** Баланс счёта SMS-сервиса: ключ живёт только на сервере, поэтому спрашиваем функцию. */
|
||||||
const fetchBalance = async () => {
|
const fetchBalance = async () => {
|
||||||
const url = import.meta.env.VITE_SUPABASE_URL || "";
|
const live = await fetchSmsBalance();
|
||||||
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
return live ? { balance: live.balance, lowBalance: live.lowBalance } : null;
|
||||||
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 = "" }) {
|
export function SmsProblemNotice({ orderGroupIds = [], className = "" }) {
|
||||||
|
|
|
||||||
|
|
@ -360,13 +360,15 @@ export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CO
|
||||||
// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся.
|
// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся.
|
||||||
// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры.
|
// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры.
|
||||||
export const AUTOCALL_STATUS_META = {
|
export const AUTOCALL_STATUS_META = {
|
||||||
not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
|
not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
|
||||||
confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
|
confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
|
||||||
no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
|
no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
|
||||||
no_input: { label: "Не выбрал день/время", short: "не выбрал день/время", tone: "warning", icon: "🤐" },
|
no_input_day: { label: "Не выбрал день", short: "не выбрал день", tone: "warning", icon: "📅" },
|
||||||
manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" },
|
no_input_half: { label: "Не выбрал время", short: "не выбрал время", tone: "warning", icon: "🕐" },
|
||||||
opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" },
|
no_input_both: { label: "Не выбрал день и время", short: "не выбрал день и время", tone: "warning", icon: "🤐" },
|
||||||
excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", 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: "not_called", label: "Не звонили" },
|
||||||
{ value: "confirmed", label: "Согласовано" },
|
{ value: "confirmed", label: "Согласовано" },
|
||||||
{ value: "no_answer", 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: "manual", label: "Нужен менеджер" },
|
||||||
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
|
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
|
||||||
{ value: "excluded", 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 } = {}) => {
|
export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => {
|
||||||
if (optOut) return "opt_out";
|
if (optOut) return "opt_out";
|
||||||
if (exclusionReason) return "excluded";
|
if (exclusionReason) return "excluded";
|
||||||
|
|
@ -387,7 +408,9 @@ export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionRea
|
||||||
const last = calls[0];
|
const last = calls[0];
|
||||||
if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed";
|
if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed";
|
||||||
if (last.outcome === "no_answer") return "no_answer";
|
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";
|
if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual";
|
||||||
return "not_called";
|
return "not_called";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
*/
|
*/
|
||||||
import { getSmsFailureReason } from "./orderGroupViews";
|
import { getSmsFailureReason } from "./orderGroupViews";
|
||||||
|
|
||||||
/** Через сколько дней после последней ошибки показываем, что всё наладилось. */
|
/** Через сколько дней после последней ошибки показываем, что всё наладилось.
|
||||||
export const RESOLVED_VISIBLE_DAYS = 7;
|
* Короткий срок намеренно: «SMS снова уходят» — сообщение на день-два, а не
|
||||||
|
* постоянный баннер на главной (владелец закрывал его, а он возвращался). */
|
||||||
|
export const RESOLVED_VISIBLE_DAYS = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Array} rows строки sms_campaign_log со статусами ошибок
|
* @param {Array} rows строки sms_campaign_log со статусами ошибок
|
||||||
|
|
@ -87,7 +89,10 @@ export const summarizeSmsFailures = (rows = [], visibleIds = [], options = {}) =
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
kind,
|
kind,
|
||||||
signature: [kind, reason || "", lastAt || "", balanceKnown ? Math.round(balance) : ""].join("|"),
|
// Отпечаток — только то, что делает сообщение НОВЫМ: вид проблемы, причина и
|
||||||
|
// время последней ошибки. Баланс сюда НЕ входит: он меняется от каждой отправки,
|
||||||
|
// из-за него закрытый баннер возвращался на следующий день.
|
||||||
|
signature: [kind, reason || "", lastAt || ""].join("|"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,19 @@ describe("summarizeSmsFailures — актуальность по балансу"
|
||||||
expect(summary).toBeNull();
|
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("ошибок нет, но денег мало — предупреждаем заранее", () => {
|
it("ошибок нет, но денег мало — предупреждаем заранее", () => {
|
||||||
const summary = summarizeSmsFailures([], [], { balance: 55 });
|
const summary = summarizeSmsFailures([], [], { balance: 55 });
|
||||||
expect(summary.kind).toBe("low_balance");
|
expect(summary.kind).toBe("low_balance");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue