supersam/src/services/smsProblemSummary.js

107 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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