337f6b9897
Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня. Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом — ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете: три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821. Мастер перестал молчать о том, что день начала ставит кабинет, а не мы. Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад». Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине владелец 36 часов не знал, что робот вообще не может войти в кабинет. Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше 1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить» называет причину и шаг, куда вернуться, а не гаснет молча. Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого теста. Сторожа доказаны вырезанием. Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
15 KiB
TypeScript
298 lines
15 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;
|
||
/**
|
||
* День начала показов «ГГГГ-ММ-ДД». Ставит его кабинет МТС (портал дат не задаёт),
|
||
* читает робот вместе с вердиктом модерации. null — робот ещё не сходил либо в
|
||
* строке кабинета даты не было.
|
||
*/
|
||
starts_on?: string | null;
|
||
/**
|
||
* Когда система последний раз ЗАКОНЧИЛА проверку этой кампании роботом. Признак
|
||
* жизни для клиента: без него кампания молча висит «На модерации», и работу от
|
||
* поломки не отличить (дефект Ф-2). null — проверок ещё не было.
|
||
*/
|
||
last_robot_check_at?: string | null;
|
||
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;
|
||
}
|