/** * @file smsProblemSummary.js * @description Складывает неудачные отправки SMS в одну понятную причину. * * В таблице видно только «SMS не отправлено», а причина в логе — код сервиса. * Здесь считаем: сколько сообщений, сколько заказов, за какой период, что * случилось чаще всего — и главное, актуальна ли проблема до сих пор (по балансу * счёта SMS-сервиса). Пополнили счёт — предупреждение гасим, а не держим месяцами. */ import { getSmsFailureReason } from "./orderGroupViews"; /** Через сколько дней после последней ошибки показываем, что всё наладилось. * Короткий срок намеренно: «SMS снова уходят» — сообщение на день-два, а не * постоянный баннер на главной (владелец закрывал его, а он возвращался). */ export const RESOLVED_VISIBLE_DAYS = 2; /** * @param {Array} rows строки sms_campaign_log со статусами ошибок * @param {Array} visibleIds id заказов, которые сейчас видны в таблице * @param {{balance?: number|null, lowBalance?: number}} options баланс счёта SMS-сервиса * @returns {null|object} null — говорить не о чем */ 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) : []; 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] = list.length ? [...reasons.entries()].sort((a, b) => b[1] - a[1])[0] : [null, 0]; const hasFundsReason = !!reason && /закончились средства/.test(reason); const visible = new Set(visibleIds); // Базовая сводка по логу (нужна в любом состоянии). const base = { reason, topMessages, messages: list.length, groups: lastByGroup.size, affectedInTable: [...lastByGroup.keys()].filter((id) => visible.has(id)).length, hasVisibleRows: visible.size > 0, 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 || ""].join("|"), }; }; /** «сообщение / сообщения / сообщений», «заказ / заказа / заказов». */ 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; };