Files
portal/app/resources/js/api/client-sms.ts
T
Дмитрий 22ac6e4f13 feat(смс-клиент): журнал рассылки открывается страницами по 50, а не одним куском
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная
разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч
номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул
из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и
человек стал открывать его чаще.

Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель.
На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер
страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы
возвращаемся туда, откуда ушли.

Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего
запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили
живьём 30 июля на базе номеров.

Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются
по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и
не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице
значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на
сервере, и на экране.

Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать.
Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит
вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом
другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не
доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд
возвращён.

Попутно поймана болезнь измерителя, уже второй раз за смену: он считал только «не сошлось» и
не видел «рухнуло», отчего доложил два красных вместо семи. Прибор обязан читать весь отчёт,
а не то поле, которое ты ждал.

Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы.
Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали —
просим первую.
2026-08-01 10:37:50 +03:00

566 lines
26 KiB
TypeScript
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.
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-модуль СМС-канала прогрева клиентов (Task 9).
*
* Эндпоинты под [auth:sanctum, tenant], префикс /api/sms. GET'ы не требуют
* CSRF-cookie, мутации (POST/PATCH/DELETE) — требуют.
*/
/** Кампания клиентского СМС-рассылки (строка GET /api/sms/campaigns, а также POST-ответ и часть GET .../{id}). */
export interface ClientSmsCampaign {
id: number;
title: string;
body: string;
sender_name: string;
source: string;
audience_days: number | null;
status: string;
segments: number;
planned_count: number;
sent_count: number;
total_sms: number;
price_rub_per_sms: string;
estimated_cost_rub: string;
actual_cost_rub: string | null;
/** Почему рассылка остановлена: 'client' (сам клиент), 'no_funds' (кончились деньги), 'watchdog'. */
stop_reason?: string | null;
/** Сколько получателей ждут своего утра (статус waiting_window). */
waiting_window_count?: number;
/** Сколько ждут, пока мы выясним их регион, — это ожидание само не пройдёт. */
waiting_region_count?: number;
/**
* Можно ли продолжить эту рассылку (строка листа 4.13). Решает СЕРВЕР — экран
* причины остановки не разбирает (В-146). Деньги в этот признак не входят: кнопка
* видна и при нехватке, а портал по нажатию говорит, сколько не хватает (В-189).
*/
can_resume?: boolean;
/** Сколько сообщений ещё уйдёт, если продолжить. */
resume_pending_count?: number;
/** Итог по судьбам этой рассылки (строка листа 5.3). Сервер считает его сам. */
delivery?: ClientSmsDeliveryTotals;
created_at?: string;
}
/**
* Итог рассылки по судьбам (строка листа 5.3): не «отправлено 900», а
* «доставлено 812, не доставлено 74, ещё в пути 14».
*
* 🔴 Считает СЕРВЕР (`ClientSmsDeliveryCounter`) — экран не складывает судьбы сам.
* Второй счётчик на экране однажды разошёлся бы с первым, а речь про деньги.
* `on_the_way` — это и явное «везу» от оператора, и те, кого мы ещё не спрашивали:
* для человека это одно состояние «пока не знаем» (В-214).
*/
export interface ClientSmsDeliveryTotals {
delivered: number;
not_delivered: number;
not_sent: number;
on_the_way: number;
}
/**
* Можно ли дослать не дошедшим и скольким (строка листа 5.6, решение владельца В-200).
*
* 🔴 Решает СЕРВЕР (`ClientSmsController::resendDecision`) — экран условие не
* повторяет. Разойдись эти два расчёта, человек нажимал бы кнопку и получал отказ,
* или не видел бы кнопки там, где досыл возможен.
*/
export interface ClientSmsResendOffer {
can: boolean;
count: number;
}
/** Отдельное сообщение кампании (строка messages[] в GET /api/sms/campaigns/{id}). */
export interface ClientSmsMessage {
id: number;
phone: string;
operator: string | null;
provider_key: string | null;
status: string;
cost_rub: string;
provider_message_id: string | null;
error: string | null;
/**
* Судьба сообщения (строка листа 5.1). 🔴 НЕ то же самое, что `status`: тот
* говорит, отдали ли мы сообщение оператору (и по нему считаются деньги),
* а это — дошло ли оно до человека. Пусто = ещё не спрашивали (В-202, В-214).
*/
delivery_status: string | null;
delivered_at: string | null;
/** Сколько частей насчитал ОПЕРАТОР. Своё число у нас в `segments` — они могут разойтись. */
provider_parts: number | null;
created_at?: string;
}
/** Контакт для рассылки (строка GET /api/sms/contacts, а также POST /api/sms/contacts ответ). */
export interface ClientSmsContact {
id: number;
phone: string;
name: string | null;
operator: string | null;
}
/** Шаблон текста СМС (строка GET /api/sms/templates, а также POST/PATCH-ответ). */
export interface ClientSmsTemplate {
id: number;
title: string;
body: string;
}
/** Ответ POST /api/sms/preview — предпросчёт стоимости и охвата рассылки. */
export interface ClientSmsPreview {
segments: number;
sendable_count: number;
/** Сколько выйдет СМС всего: номера × сколько СМС в тексте. За это и платят (В-120). */
total_sms?: number;
skipped: Record<string, number>;
estimated_cost_rub: string;
/** Цена этой рассылки — ступень для «накоплено за месяц + этот заказ» (3.8). */
price_rub_per_sms: string;
/** Ступень БЕЗ этого заказа — чтобы экран мог сказать про удешевление (3.12). */
price_rub_per_sms_before?: string;
/** Сколько СМС клиент отправил с 1-го числа, по московскому календарю (3.11). */
month_sent_count?: number;
/**
* Когда рассылка реально начнётся (3.15). Пусто = сразу. Момент СЕРВЕРНЫЙ:
* на компьютере клиента может стоять что угодно (В-117).
*/
starts_at?: string | null;
/** «Сейчас» по часам сервера — пара к starts_at. */
server_now?: string;
/** Сколько номеров ждут, пока мы выясним их регион (В-116). */
waiting_region_count?: number;
/**
* Сколько номеров из «своего списка» портал не разобрал (строка листа 4.8).
* Для сделок и своей базы всегда 0 — там номера пришли из базы, а не от человека.
*/
rejected_count?: number;
/** Образцы непонятых номеров — не больше двадцати, чтобы ответ не раздувать. */
rejected_samples?: string[];
}
/** Ответ GET /api/sms/campaigns — список кампаний тенанта + режим отправки + имя отправителя. */
export interface ClientSmsIndex {
campaigns: ClientSmsCampaign[];
sandbox: boolean;
sender_name: string;
balance_rub: string;
frozen_rub: string;
}
/** Тело POST /api/sms/preview и (без title) базис POST /api/sms/campaigns. */
export interface ClientSmsPreviewPayload {
body: string;
source: 'deals' | 'base' | 'manual';
audience_days?: number;
/**
* Статусы воронки, оставленные отмеченными (строки 2.6–2.7). Только для «по сделкам».
* Пустой список сервер понимает как «все статусы», а не «никому» (решение В-42).
*/
audience_statuses?: string[];
phones?: string[];
}
/** Тело POST /api/sms/campaigns — создание кампании. */
export interface ClientSmsCreatePayload extends ClientSmsPreviewPayload {
title: string;
/**
* Ключ заказа (строка листа 1.18). Один и тот же ключ = один и тот же заказ:
* двойной клик, обрыв связи и повтор браузера вернут первую рассылку и НЕ
* спишут деньги второй раз. Генерируется экраном при открытии формы.
*/
idempotency_key?: string;
/**
* «Да, знаю, что похожая рассылка была недавно, всё равно отправляй»
* (строка листа 1.20). Ставится только после ответа человека на вопрос,
* который сервер задаёт кодом `duplicate_recent` (строка 1.19).
*/
confirmed?: boolean;
}
/** GET /api/sms/campaigns — список кампаний тенанта. */
export async function fetchClientSms(): Promise<ClientSmsIndex> {
const { data } = await apiClient.get<ClientSmsIndex>('/api/sms/campaigns');
return data;
}
/** POST /api/sms/preview — предпросчёт стоимости и охвата рассылки. */
export async function previewClientSms(payload: ClientSmsPreviewPayload): Promise<ClientSmsPreview> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ClientSmsPreview>('/api/sms/preview', payload);
return data;
}
/** POST /api/sms/campaigns — создать кампанию. */
export async function createClientSms(payload: ClientSmsCreatePayload): Promise<ClientSmsCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ClientSmsCampaign>('/api/sms/campaigns', payload);
return data;
}
/**
* Где мы в журнале рассылки: сколько всего строк, какая страница, по скольку.
* Числа приходят с сервера и на экране НЕ пересчитываются (В-123).
*/
export interface ClientSmsJournalPage {
total: number;
page: number;
per_page: number;
last_page: number;
}
/**
* GET /api/sms/campaigns/{id} — детали кампании + СТРАНИЦА сообщений + итог по судьбам.
*
* `delivery` помечен необязательным намеренно: сервер его отдаёт всегда, но экран
* обязан пережить ответ без него (старая вкладка, ответ из кэша браузера) и не
* написать человеку «доставлено undefined». То же и про `messages_page`.
*
* 🔴 Итог по судьбам и предложение досыла сервер считает по ВСЕЙ рассылке, а не по
* этой странице: листнув, человек обязан видеть тот же итог.
*/
export async function fetchClientSmsCampaign(
id: number,
page = 1,
): Promise<{
campaign: ClientSmsCampaign;
messages: ClientSmsMessage[];
messages_page?: ClientSmsJournalPage;
delivery?: ClientSmsDeliveryTotals;
resend?: ClientSmsResendOffer;
}> {
const { data } = await apiClient.get<{
campaign: ClientSmsCampaign;
messages: ClientSmsMessage[];
messages_page?: ClientSmsJournalPage;
delivery?: ClientSmsDeliveryTotals;
resend?: ClientSmsResendOffer;
}>(`/api/sms/campaigns/${id}`, { params: { page } });
return data;
}
/**
* POST /api/sms/campaigns/{id}/resend — дослать не дошедшим (строка листа 5.6).
*
* 🔴 Это ДЕНЬГИ: досыл — отдельная рассылка, и платится она отдельно (решение
* владельца В-198/В-200). Повторное нажатие второй рассылки не заводит — на сервере
* стоит постоянный ключ заказа.
*/
export async function resendClientSms(id: number): Promise<void> {
await apiClient.post(`/api/sms/campaigns/${id}/resend`);
}
/**
* Ответ GET /api/sms/contacts — список своей базы и потолок загрузки.
*
* Объект, а не голый массив: экран обязан знать потолок ДО загрузки, а
* выдумывать число на экране нельзя — только с сервера (строка листа 4.6).
*/
export interface ContactsListResult {
items: ClientSmsContact[];
max_upload_phones: number;
/** Сколько номеров в базе ВСЕГО, а не на этой странице (строка листа 4.7). */
total: number;
page: number;
per_page: number;
last_page: number;
}
/**
* GET /api/sms/contacts — СТРАНИЦА своей базы + потолок загрузки.
*
* База бывает на десятки тысяч номеров, поэтому сервер отдаёт её частями и
* больше своего потолка не отдаёт никому (строка листа 4.7).
*/
export async function fetchContacts(page = 1): Promise<ContactsListResult> {
const { data } = await apiClient.get<ContactsListResult>('/api/sms/contacts', { params: { page } });
return data;
}
/** Ответ на добавление номеров в базу (вставкой или файлом). */
export interface ContactsUploadResult {
added: number;
rejected: number;
rejected_samples: string[];
}
/** POST /api/sms/contacts — загрузить список телефонов (вставка текстом). */
export async function uploadContacts(phones: string[]): Promise<ContactsUploadResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ContactsUploadResult>('/api/sms/contacts', { phones });
return data;
}
/** POST /api/sms/contacts/file — загрузить базу Excel-файлом (первая колонка — номера). */
export async function uploadContactsFile(file: File): Promise<ContactsUploadResult> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
const { data } = await apiClient.post<ContactsUploadResult>('/api/sms/contacts/file', form);
return data;
}
/** Адрес примера Excel-файла базы — скачивается по ссылке (cookie SPA GET). */
export function contactsExampleUrl(): string {
return '/api/sms/contacts/example';
}
/** DELETE /api/sms/contacts/{id} — удалить контакт. */
export async function deleteContact(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/sms/contacts/${id}`);
}
// ─── Этап 1: «Не писать этим» + остановка рассылки ───────────────────────────
/** Номер в стоп-листе самого клиента (строка GET /api/sms/optouts). */
export interface ClientSmsOptout {
id: number;
phone: string;
/** `client` — внёс клиент, `admin` — внесли со стороны портала. */
source: string;
note: string | null;
}
/** Ответ на внесение номеров в «Не писать этим» — руками или файлом. */
export interface OptoutsUploadResult {
added: number;
rejected: number;
rejected_samples: string[];
}
/** GET /api/sms/optouts — номера, которым клиент запретил писать. */
export async function fetchOptouts(): Promise<ClientSmsOptout[]> {
const { data } = await apiClient.get<{ items: ClientSmsOptout[] }>('/api/sms/optouts');
return data.items;
}
/** POST /api/sms/optouts — внести номера руками. Комментарий один на всю пачку. */
export async function addOptouts(phones: string[], note: string | null): Promise<OptoutsUploadResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<OptoutsUploadResult>('/api/sms/optouts', { phones, note });
return data;
}
/** POST /api/sms/optouts/file — внести номера Excel-файлом (первая колонка — номера). */
export async function uploadOptoutsFile(file: File, note: string | null): Promise<OptoutsUploadResult> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
if (note !== null && note !== '') form.append('note', note);
const { data } = await apiClient.post<OptoutsUploadResult>('/api/sms/optouts/file', form);
return data;
}
/** DELETE /api/sms/optouts/{id} — убрать номер из «Не писать этим». */
export async function deleteOptout(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/sms/optouts/${id}`);
}
/**
* POST /api/sms/campaigns/{id}/cancel — остановить рассылку (строка листа 1.14).
* Уже начатое одно сообщение доводится до конца, новые не уходят. 409 — рассылку
* уже нельзя остановить (закончилась или отменена).
*/
export async function cancelClientSms(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/sms/campaigns/${id}/cancel`);
}
/**
* POST /api/sms/campaigns/{id}/resume — продолжить рассылку, вставшую из-за денег
* (строка листа 4.13). Уже отправленным повторно не шлём, тому, на кого денег не
* хватило, шлём обязательно. 422 — продолжить нельзя (не та рассылка) либо денег
* по-прежнему не хватает: в `message` человеческое объяснение с числами.
*/
export async function resumeClientSms(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/sms/campaigns/${id}/resume`);
}
/** GET /api/sms/templates — список шаблонов текста СМС. */
export async function fetchTemplates(): Promise<ClientSmsTemplate[]> {
const { data } = await apiClient.get<ClientSmsTemplate[]>('/api/sms/templates');
return data;
}
/** POST /api/sms/templates — создать шаблон. */
export async function saveTemplate(title: string, body: string): Promise<ClientSmsTemplate> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ClientSmsTemplate>('/api/sms/templates', { title, body });
return data;
}
/** PATCH /api/sms/templates/{id} — изменить шаблон. */
export async function updateTemplate(id: number, title: string, body: string): Promise<ClientSmsTemplate> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<ClientSmsTemplate>(`/api/sms/templates/${id}`, { title, body });
return data;
}
/** DELETE /api/sms/templates/{id} — удалить шаблон. */
export async function deleteTemplate(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/sms/templates/${id}`);
}
// ─── Этап 2: своё имя отправителя + авто-СМС ─────────────────────────────────
/** Вид имени = какой документ подтверждает право (требование МТС). */
export type ClientSmsSenderNameType = 'legal' | 'ip' | 'website' | 'trademark' | 'company';
/** Имя отправителя клиента (Task 16a). */
export interface ClientSmsSender {
id: number;
name: string;
name_type: ClientSmsSenderNameType;
status: 'pending' | 'active' | 'suspended' | 'rejected' | 'cancelled';
monthly_fee_rub: string;
note: string | null;
paid_until: string | null;
doc_original_name: string | null;
consent_doc_original_name: string | null;
}
/** Тип лица клиента (из реквизитов) — определяет доступные виды имени. */
export type ClientSubjectType = 'individual' | 'sole_proprietor' | 'legal_entity' | null;
/** Ответ GET /api/sms/sender — заявка (или null) + эффективное имя + цена/мес + готовы ли реквизиты + тип лица. */
export interface ClientSmsSenderInfo {
sender: ClientSmsSender | null;
effective_name: string;
name_fee_rub_per_operator: string;
requisites_ready: boolean;
subject_type: ClientSubjectType;
/**
* Можно ли клиенту вернуть имя в работу кнопкой и сколько за это спишется.
* Решает СЕРВЕР (строка листа 4.5, журнал В-146): включать можно не всякое
* отключённое имя, и списывается не всегда полная плата. Экран ничего не
* считает — рисует кнопку по флагу и печатает присланную сумму.
*/
can_enable: boolean;
enable_charge_rub: string;
}
/** Авто-СМС: одно сообщение каждому новому лиду (GET/POST /api/sms/auto-rule). */
export interface ClientSmsAutoRule {
enabled: boolean;
body: string;
sender_name: string;
estimated_cost_rub: string;
}
/**
* Журнал авто-СМС за последние 30 дней (GET /api/sms/auto-log, строка листа
* 4.11). Числа сервер считает по всему окну, а `messages` — последние сто
* событий: журнал, а не выгрузка. `messages_truncated` говорит экрану, что
* событий было больше, чем показано (В-170).
*/
export interface ClientSmsAutoLogEntry {
id: number;
phone: string;
status: string;
cost_rub: string;
created_at: string;
}
export interface ClientSmsAutoLogReason {
reason: string;
count: number;
}
export interface ClientSmsAutoLog {
since: string;
totals: { sent: number; skipped: number };
reasons: ClientSmsAutoLogReason[];
messages: ClientSmsAutoLogEntry[];
messages_limit: number;
messages_truncated: boolean;
}
export async function fetchAutoLog(): Promise<ClientSmsAutoLog> {
const { data } = await apiClient.get<ClientSmsAutoLog>('/api/sms/auto-log');
return data;
}
/** GET /api/sms/sender — заявка на своё имя + эффективное имя + цена. */
export async function fetchSender(): Promise<ClientSmsSenderInfo> {
const { data } = await apiClient.get<ClientSmsSenderInfo>('/api/sms/sender');
return data;
}
/**
* URL готового бланка письма-разрешения в редактируемом Word (.docx) с
* подставленными реквизитами клиента (чего нет — прочерки). Клиент дописывает
* недостающее и паспорт перед подписью. Открывается прямой ссылкой (GET,
* cookie-сессия того же домена) — браузер скачивает файл.
*/
export function consentFormUrl(
name: string,
name_type: ClientSmsSenderNameType,
owner_type?: ClientSubjectType,
owner_name?: string,
): string {
const q = new URLSearchParams({ name, name_type });
// Домен/товарный знак могут быть на физлице (директоре/владельце), даже если
// клиент — юрлицо/ИП: тогда письмо оформляется от этого физлица.
if (owner_type) q.set('owner_type', owner_type);
if (owner_name && owner_name.trim() !== '') q.set('owner_name', owner_name.trim());
return `/api/sms/sender/consent-form?${q.toString()}`;
}
/**
* POST /api/sms/sender — заказать своё имя отправителя. Имя регистрирует Лидерра
* от лица клиента, поэтому multipart с ДВУМЯ файлами: подписанное согласие
* (бланк готовит портал) и документ-основание (право на имя). Оба обязательны.
*/
export async function requestSender(
name: string,
name_type: ClientSmsSenderNameType,
consentDocument: File,
document: File,
): Promise<ClientSmsSender> {
await ensureCsrfCookie();
const form = new FormData();
form.append('name', name);
form.append('name_type', name_type);
form.append('consent_document', consentDocument);
form.append('document', document);
const { data } = await apiClient.post<ClientSmsSender>('/api/sms/sender', form);
return data;
}
/** POST /api/sms/sender/disable — отключить / отменить своё имя. */
export async function disableSender(): Promise<{ disabled: boolean; sender: ClientSmsSender }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ disabled: boolean; sender: ClientSmsSender }>('/api/sms/sender/disable');
return data;
}
/**
* POST /api/sms/sender/enable — вернуть своё имя в работу (строка листа 4.5).
* Списывает плату за месяц и включает сразу (решение владельца В-134); денег не
* хватает — сервер отвечает 422 с текстом, где названа недостающая сумма.
*/
export async function enableSender(): Promise<{ enabled: boolean; charged_rub: string; sender: ClientSmsSender }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ enabled: boolean; charged_rub: string; sender: ClientSmsSender }>(
'/api/sms/sender/enable',
);
return data;
}
/** GET /api/sms/auto-rule — правило авто-СМС новым лидам. */
export async function fetchAutoRule(): Promise<ClientSmsAutoRule> {
const { data } = await apiClient.get<ClientSmsAutoRule>('/api/sms/auto-rule');
return data;
}
/** POST /api/sms/auto-rule — сохранить правило авто-СМС. */
export async function saveAutoRule(enabled: boolean, body: string): Promise<ClientSmsAutoRule> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ClientSmsAutoRule>('/api/sms/auto-rule', { enabled, body });
return data;
}