Files
portal/app/resources/js/api/telegram.ts
T
Дмитрий f2ac3f4c7f fix,телеграм: подсказка обещала цену вдвое ниже настоящей + образец файла для базы номеров
Приёмка владельца на боевом, два замечания из трёх (третье — про рекламный
кошелёк и заморозку — отложено, кусок большой).

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>
2026-08-03 01:59:01 +03:00

286 lines
14 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';
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;
}