f2ac3f4c7f
Приёмка владельца на боевом, два замечания из трёх (третье — про рекламный кошелёк и заморозку — отложено, кусок большой). 1. Подсказка «?» у поля медиа обещала «600 ₽ за тысячу с картинкой, 680 ₽ с видео». Клиент платит 1008 и 1142,40 ₽. Числа были вбиты в текст руками и протухли в ту минуту, когда миграция client_tg_cena_po_media поменяла тариф. Лечение в корень, а не подстановкой верных чисел: цену называет тот, кто её считает. Ручка оценки отдаёт ceny_za_tysyachu по каждому виду медиа (себестоимость × наценка), подсказка собирается из них функцией podskazkaProMedia. Пока сервер не ответил — текст без единой цифры: подставить «примерные» числа значило бы вернуть ровно эту беду. Прайс держим отдельно от охвата: охват на ошибке гасим (устаревший хуже никакого), а цены от настроек аудитории не зависят — иначе подсказка мигала бы на каждой опечатке в поле. 2. Замечание дословно: «нету скачать файл с примером как надо заполнить для нас файл». Кнопка «Скачать образец» рядом с полем загрузки — подпись клиент читает уже ПОСЛЕ того, как файл отклонили. Пять строк, написания разные (с плюсом, с восьмёркой, со скобками), номера синтетические 7999. Заголовка-строки в образце намеренно нет: разборщик нормализует первый столбец КАЖДОЙ строки, и слово «Телефон» попало бы в «не похоже на номер» — наш собственный образец показал бы клиенту ошибку. Проверено вырезанием, а не только зелёным: - вернул в подсказку вбитые 600/680 — покраснели 4 датчика, включая тот, что прямо запрещает эти два числа; - вставил в образец строку-заголовок — покраснели 3. Полный прогон поймал две мои же поломки, обе настоящие: - значок mdi-file-download-outline на новой кнопке ОТСУТСТВОВАЛ в карте Lucide — на экране стал бы вопросом в кружке. Поймал сторож значков, у которого вчера опустошили список поблажек. Добавлен (Download — точного «файла со стрелкой» в Lucide нет); - датчик подсказок ждал PODSKAZKI.media строкой, а её больше нет. Замеры: экраны 245 файлов / 1870 тестов / 0 падений; телеграм-модуль 327 / 0; статанализ 0; формат чисто; типы 6 — все в чужих файлах, столько же было до; сборка 3,80 с. Счётчик в phpstan-baseline сдвинут 13→15 (новые тесты на Pest), diff проверен глазами: изменилась ровно эта строка. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
286 lines
14 KiB
TypeScript
286 lines
14 KiB
TypeScript
import { apiClient, ensureCsrfCookie } from './client';
|
||
import type { CenyZaTysyachu } from '../composables/podskazkiReklamy';
|
||
|
||
/**
|
||
* API-модуль клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.2).
|
||
*
|
||
* Эндпоинты под [auth:sanctum, tenant], префикс /api/telegram. GET'ы не требуют
|
||
* CSRF-cookie, мутации (POST) — требуют.
|
||
*
|
||
* 🔴 Отличие от СМС-близнеца: создание (create) и запуск (launch) РАЗДЕЛЕНЫ.
|
||
* create возвращает ЧЕРНОВИК со сметой и числом кандидатов; launch переводит его
|
||
* в очередь и запускает робота кабинета МТС (в песочнице — только черновик).
|
||
*/
|
||
|
||
/** Способ выбора аудитории: свежие сделки за период / своя база / свой список. */
|
||
export type TelegramAudienceKind = 'deals' | 'base' | 'list';
|
||
|
||
/** Кампания клиентской Telegram-рекламы (строка списка, POST-ответ и часть GET .../{id}). */
|
||
export interface TelegramCampaign {
|
||
id: number;
|
||
status: string;
|
||
status_reason?: string | null;
|
||
ad_text: string;
|
||
ad_link: string;
|
||
media_path: string | null;
|
||
/** Документ модератору, приложённый при пересдаче отклонённой кампании (задача 3.6). */
|
||
moderator_file_path?: string | null;
|
||
ord_category: string;
|
||
budget_cap_rub: string;
|
||
audience_kind: TelegramAudienceKind;
|
||
audience_params: Record<string, unknown> | null;
|
||
planned_count: number;
|
||
matched_count: number | null;
|
||
estimated_cost_rub: string;
|
||
actual_cost_rub: string | null;
|
||
/** null = авто-кампания (накопитель), иначе id создавшего менеджера. */
|
||
created_by?: number | null;
|
||
created_at?: string;
|
||
}
|
||
|
||
/** Ответ GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
|
||
export interface TelegramIndex {
|
||
campaigns: TelegramCampaign[];
|
||
sandbox: boolean;
|
||
balance_rub: string;
|
||
frozen_rub: string;
|
||
}
|
||
|
||
/** Тело POST /api/telegram/campaigns — создание черновика кампании. */
|
||
export interface TelegramCreatePayload {
|
||
ad_text: string;
|
||
ad_link: string;
|
||
audience_kind: TelegramAudienceKind;
|
||
budget_cap_rub: string;
|
||
audience_days?: number;
|
||
ord_category?: string;
|
||
phones?: string[];
|
||
/** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */
|
||
ad_headline?: string;
|
||
}
|
||
|
||
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
|
||
export async function fetchTelegram(): Promise<TelegramIndex> {
|
||
const { data } = await apiClient.get<TelegramIndex>('/api/telegram/campaigns');
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/telegram/campaigns — создать ЧЕРНОВИК кампании (деньги/робот не трогаются). */
|
||
export async function createTelegram(payload: TelegramCreatePayload): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<TelegramCampaign>('/api/telegram/campaigns', payload);
|
||
return data;
|
||
}
|
||
|
||
/** Ответ оценки охвата: сколько людей и во сколько обойдётся, плюс порог площадки. */
|
||
export interface TelegramOcenka {
|
||
planned_count: number;
|
||
estimated_cost_rub: string;
|
||
/** Минимум кандидатов у МТС Маркетолог — правило площадки, не наше. */
|
||
min_count: number;
|
||
enough: boolean;
|
||
/**
|
||
* Цена ЗА ТЫСЯЧУ показов по каждому виду медиа, ₽ — то, что заплатит клиент.
|
||
*
|
||
* 🔴 Экран не имеет права хранить эти числа у себя. Подсказка «?» у поля медиа
|
||
* держала их в тексте («600 ₽ с картинкой, 680 ₽ с видео») и протухла в ту минуту,
|
||
* когда миграция поменяла тариф: клиенту обещали вдвое дешевле, чем списывали.
|
||
*/
|
||
ceny_za_tysyachu: CenyZaTysyachu;
|
||
}
|
||
|
||
/** Что спрашиваем у оценки — только про аудиторию, объявление на охват не влияет. */
|
||
export interface TelegramOcenkaPayload {
|
||
audience_kind: TelegramAudienceKind;
|
||
audience_days?: number;
|
||
phones?: string[];
|
||
/**
|
||
* Вид медиа объявления: у МТС показ с картинкой и видео стоит дороже, и смета
|
||
* обязана считаться по тому, что клиент выбрал. Не передан — считаем без медиа.
|
||
*/
|
||
media_kind?: 'none' | 'image' | 'video';
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/campaigns/estimate — сколько людей увидит рекламу и во сколько
|
||
* это обойдётся, БЕЗ создания кампании.
|
||
*
|
||
* 🔴 Не путать с `createTelegram`: та тоже возвращает охват, но СОЗДАЁТ черновик в базе.
|
||
* Живой счётчик дёргается на каждое изменение поля, и через неё он засорил бы клиенту
|
||
* список кампаний десятками призраков.
|
||
*/
|
||
export async function estimateTelegram(payload: TelegramOcenkaPayload): Promise<TelegramOcenka> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<TelegramOcenka>('/api/telegram/campaigns/estimate', payload);
|
||
return data;
|
||
}
|
||
|
||
/** GET /api/telegram/campaigns/{id} — детали кампании. */
|
||
export async function fetchTelegramCampaign(id: number): Promise<{ campaign: TelegramCampaign }> {
|
||
const { data } = await apiClient.get<{ campaign: TelegramCampaign }>(`/api/telegram/campaigns/${id}`);
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/telegram/campaigns/{id}/launch — запустить черновик (draft → queued + робот). */
|
||
export async function launchTelegram(id: number): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/launch`);
|
||
return data;
|
||
}
|
||
|
||
/** Тело POST /api/telegram/campaigns/{id}/resubmit — правки + опц. документ модератору. */
|
||
export interface TelegramResubmitPayload {
|
||
ad_text: string;
|
||
ad_link: string;
|
||
ord_category?: string;
|
||
/** Лицензия/договор для модератора МТС (png/jpg/pdf ≤10 МБ). */
|
||
moderator_file?: File | null;
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/campaigns/{id}/resubmit — пересдать ОТКЛОНЁННУЮ кампанию
|
||
* (rejected → queued). Отправляем multipart, т.к. можно приложить документ модератору.
|
||
*/
|
||
export async function resubmitTelegram(id: number, payload: TelegramResubmitPayload): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('ad_text', payload.ad_text);
|
||
form.append('ad_link', payload.ad_link);
|
||
if (payload.ord_category) {
|
||
form.append('ord_category', payload.ord_category);
|
||
}
|
||
if (payload.moderator_file) {
|
||
form.append('moderator_file', payload.moderator_file);
|
||
}
|
||
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/resubmit`, form);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Что кабинет МТС принимает в поле медиа (снято глазами 02.08.2026). Список идёт
|
||
* в атрибут accept у полей загрузки — чтобы клиенту в проводнике не показывали
|
||
* заведомо негодные файлы. Настоящая проверка — на сервере (правило MtsMedia).
|
||
*/
|
||
export const MEDIA_ACCEPT =
|
||
'image/jpeg,image/png,video/mp4,video/webm,video/quicktime,video/x-matroska,video/mpeg,video/x-m4v,video/x-ms-wmv';
|
||
|
||
/** Требования МТС к медиа человеческим языком — подсказка под полем загрузки. */
|
||
export const MEDIA_TREBOVANIYA =
|
||
'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880. Видео: до 20 МБ, 3–55 секунд, от 640×360.';
|
||
|
||
/**
|
||
* Показ с медиа дороже обычного — клиент должен знать это ДО загрузки файла, а не
|
||
* после списания денег.
|
||
*
|
||
* 🔴 Чисел здесь намеренно НЕТ. Раньше стояло «600 ₽ за тысячу, с видео — 680 ₽» —
|
||
* это была закупочная цена МТС без НДС, а не то, что платит клиент; вдобавок она
|
||
* протухала бы при любой правке тарифа в админке. Точную сумму считает сервер и
|
||
* показывает смета — она знает и про медиа, и про наценку.
|
||
*/
|
||
export const MEDIA_CENA =
|
||
'Картинка и видео делают показ дороже. Смета пересчитается сама, как только выберете файл.';
|
||
|
||
/**
|
||
* POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ
|
||
* объявления. Требования — см. MEDIA_TREBOVANIYA. Робот отдаёт файл кабинету МТС
|
||
* при запуске.
|
||
*/
|
||
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('media', file);
|
||
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/media`, form);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Правило авто-рекламы Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент
|
||
* из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит.
|
||
*/
|
||
export interface TelegramAutoRule {
|
||
enabled: boolean;
|
||
ad_text: string;
|
||
ad_link: string;
|
||
/** Заголовок объявления — МТС требует его, когда реклама ведёт на САЙТ (до 40 знаков). */
|
||
ad_headline: string;
|
||
/** Картинка/видео объявления; грузится отдельно (uploadAutoRuleMedia), в PUT не ходит. */
|
||
media_path?: string | null;
|
||
ord_category: string;
|
||
budget_cap_rub: string;
|
||
daily_limit_rub: string;
|
||
/** Сколько кандидатов накопить перед отправкой пачки; null → порог по умолчанию (367). */
|
||
batch_threshold: number | null;
|
||
}
|
||
|
||
/** GET /api/telegram/auto-rule — правило авто-рекламы тенанта (или дефолты). */
|
||
export async function fetchAutoRule(): Promise<TelegramAutoRule> {
|
||
const { data } = await apiClient.get<TelegramAutoRule>('/api/telegram/auto-rule');
|
||
return data;
|
||
}
|
||
|
||
/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */
|
||
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/auto-rule/media — приложить картинку/видео к авто-правилу.
|
||
* Оно повторяется в каждой авто-пачке. Правило должно быть уже сохранено — иначе 422.
|
||
*/
|
||
export async function uploadAutoRuleMedia(file: File): Promise<TelegramAutoRule> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('media', file);
|
||
const { data } = await apiClient.post<TelegramAutoRule>('/api/telegram/auto-rule/media', form);
|
||
return data;
|
||
}
|
||
|
||
// --- «Моя база номеров» (client_tg_contacts) --------------------------------
|
||
//
|
||
// 🔴 Наружу ходят только счётчики. Номера — персональные данные, экрану они не нужны:
|
||
// он показывает «в базе N номеров», а сами номера остаются на сервере.
|
||
|
||
/** Сколько номеров в базе тенанта. */
|
||
export interface TgBazaRazmer {
|
||
count: number;
|
||
}
|
||
|
||
/** Итог заливки файла: что приняли, что схлопнули, что не разобрали. */
|
||
export interface TgBazaItog extends TgBazaRazmer {
|
||
accepted: number;
|
||
duplicates: number;
|
||
rejected: number;
|
||
/** Файл оказался длиннее потолка — взяли первые `max` номеров. */
|
||
oborvano: boolean;
|
||
max: number;
|
||
}
|
||
|
||
/** GET /api/telegram/contacts — размер базы. */
|
||
export async function fetchTgBaza(): Promise<TgBazaRazmer> {
|
||
const { data } = await apiClient.get<TgBazaRazmer>('/api/telegram/contacts');
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/contacts — залить файл с номерами.
|
||
*
|
||
* 🪤 ЗАМЕНЯЕТ базу целиком, а не добавляет к ней. Экран обязан сказать об этом до
|
||
* нажатия, иначе клиент узнает о потере после.
|
||
*/
|
||
export async function uploadTgBaza(file: File): Promise<TgBazaItog> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const { data } = await apiClient.post<TgBazaItog>('/api/telegram/contacts', form);
|
||
return data;
|
||
}
|
||
|
||
/** DELETE /api/telegram/contacts — очистить базу целиком. */
|
||
export async function clearTgBaza(): Promise<TgBazaRazmer> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.delete<TgBazaRazmer>('/api/telegram/contacts');
|
||
return data;
|
||
}
|