Files
portal/app/resources/js/api/client-sms.ts
T
Дмитрий a5451bef76 feat(смс-клиент): галочки статусов воронки в рассылке по сделкам
Строки приёмочного листа 2.6 и 2.7. Клиент выбирает, каким сделкам слать:
пять галочек воронки (Новая сделка, Просмотрено, В работе, Сделка,
Не реализовано), по умолчанию отмечены все.

Отбор применяется в ClientSmsAudienceBuilder::fromDeals() — через него идут
и смета предпросмотра, и снимок получателей при запуске, поэтому число на
экране и факт отправки остаются одним списком.

Пустой список = «все статусы», отдельного значения «никому» нет (решение
В-42): экран не даёт снять последнюю галочку и объясняет почему. Новая
колонка audience_statuses (jsonb, NULL = «все») — уже созданные рассылки
поведения не меняют. Права не нужны: колонка наследует права таблицы.

Живой прогон поймал дефект, которого не видели тесты: запрет снять
последнюю галочку не работал вообще — галочка Vuetify правит список на
месте, и обычное наблюдение этого не видело, а тест присваивал новый
список. Лечение: наблюдение вглубь + возврат после отрисовки; тест
переписан на правку списка на месте.

Тесты: 5 новых серверных + 4 на экране, ClientSms 156/156, приём лидов
17/17, фронт 1652 зелёных. Живой прогон: 5 → 4 получателя после снятия
«Не реализовано»; рассылка только со статусом «Сделка» ушла ровно на номер
этой сделки. Журнал схемы: v9.09 (эта работа) и v9.08 — пропущенная запись
о снимке получателей, дописана задним числом.
2026-07-28 08:09:04 +03:00

365 lines
16 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;
created_at?: string;
}
/** Отдельное сообщение кампании (строка 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;
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;
skipped: Record<string, number>;
estimated_cost_rub: string;
price_rub_per_sms: 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;
}
/** GET /api/sms/campaigns/{id} — детали кампании + список сообщений. */
export async function fetchClientSmsCampaign(
id: number,
): Promise<{ campaign: ClientSmsCampaign; messages: ClientSmsMessage[] }> {
const { data } = await apiClient.get<{ campaign: ClientSmsCampaign; messages: ClientSmsMessage[] }>(
`/api/sms/campaigns/${id}`,
);
return data;
}
/** GET /api/sms/contacts — список контактов для рассылки. */
export async function fetchContacts(): Promise<ClientSmsContact[]> {
const { data } = await apiClient.get<ClientSmsContact[]>('/api/sms/contacts');
return data;
}
/** Ответ на добавление номеров в базу (вставкой или файлом). */
export interface ContactsUploadResult {
added: number;
rejected: number;
rejected_samples: string[];
contacts: ClientSmsContact[];
}
/** 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`);
}
/** 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;
}
/** Авто-СМС: одно сообщение каждому новому лиду (GET/POST /api/sms/auto-rule). */
export interface ClientSmsAutoRule {
enabled: boolean;
body: string;
sender_name: string;
estimated_cost_rub: string;
}
/** 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;
}
/** 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;
}