a5451bef76
Строки приёмочного листа 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 — пропущенная запись о снимке получателей, дописана задним числом.
365 lines
16 KiB
TypeScript
365 lines
16 KiB
TypeScript
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;
|
||
}
|