Files
portal/app/resources/js/api/client-sms.ts
T
Дмитрий 7bd9c67053
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(смс): экран показывает, что будет с рассылкой в КАЖДОЙ сети
Одно имя «на всех» стало полуправдой 05.08.2026: номера с несогласованным именем
перестали уходить вовсе, а к Билайну и МегаФону канала пока нет. Экран продолжал
обещать отправку — половина базы исчезала бы для клиента молча.

Теперь на экране «Имя отправителя» разложено по четырём сетям владельца:

  ✓ МТС      уйдёт под именем «mybrand.ru»
  ✗ Билайн   не уйдёт — сюда мы пока не шлём
  ✗ МегаФон  не уйдёт — сюда мы пока не шлём
  ✗ Теле2    не уйдёт — ваше имя у этого оператора не согласовано

Показываем ВСЕ четыре сети, включая недоступные: клиент, у которого половина базы
на Билайне, должен видеть, куда она делась, а не гадать.

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

Названия операторов словами — на ЭКРАНЕ, не на сервере: сервер отдаёт ключ и
причину, как назвать это человеку — дело интерфейса (так же в соседних экранах).

Тесты экрана — с настоящим монтированием, а не сверкой исходника: проверяем, что
надпись дошла до глаз, а не что нужное слово где-то есть в файле.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:20:58 +03:00

586 lines
28 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;
/**
* Что будет с рассылкой в ОДНОЙ сети (строка раскладки по операторам).
*
* Причины приходят ключами, а не текстом: как назвать это человеку — дело
* экрана. `sender_name` заполнено только когда уйдёт СВОЁ имя клиента; пусто
* значит «подпишется имя Лидерры» либо «не уйдёт вовсе».
*/
export interface ClientSmsSenderPerOperator {
operator: string;
will_send: boolean;
reason: 'own_name' | 'liderra_name' | 'name_not_approved' | 'no_channel' | 'operator_denied';
sender_name: string | null;
}
/** Ответ GET /api/sms/sender — заявка (или null) + эффективное имя + цена/мес + готовы ли реквизиты + тип лица. */
export interface ClientSmsSenderInfo {
sender: ClientSmsSender | null;
effective_name: string;
/**
* Что будет в КАЖДОЙ сети. Одно `effective_name` на всех — полуправда с
* 05.08.2026: номера с несогласованным именем не уходят вовсе, а к части
* операторов канала пока нет.
*/
per_operator: ClientSmsSenderPerOperator[];
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;
}