feat,телеграм-реклама: «Моя база номеров» заработала — пункт больше не ведёт в никуда
Это снимает запрет на выкат телеграм-рекламы. Пункт «Моя база номеров» стоял в выборе аудитории с 27.07, а писать в таблицу client_tg_contacts не умела НИ ОДНА строка кода: ни экрана загрузки, ни серверной ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль. Клиент выбрал бы «свою базу», увидел пустоту и решил, что портал потерял его клиентов. Песочница выключена, деньги живые — катить в таком виде было нельзя. Читающая половина при этом была готова с самого начала: TelegramAudienceService умеет и нормализацию, и схлопывание дублей, и вычитание стоп-листа. Не хватало только записи, поэтому работа вышла куда меньше, чем казалось. Сервер: - TelegramBazaService — разбор файла, замена базы, очистка. Номер берётся по одному в строке либо первым столбцом таблицы, разделители запятая, точка с запятой, табуляция. Мусорные строки не роняют разбор, а считаются отдельно. - три ручки: GET, POST и DELETE /api/telegram/contacts. - потолок 200 000 номеров и 10 МБ на файл; обрыв по потолку не молчит, а возвращается признаком. Экран, в шаге «Кому показываем»: - сколько номеров в базе, заливка файла, очистка; - итог заливки целиком: принято, повторов, не похоже на номер. «Принято 1200» без остального читалось бы как «файл зашёл полностью»; - после заливки счётчик охвата пересчитывается сам. Решения, которые стоит знать: - заливка ЗАМЕНЯЕТ базу, а не добавляет. Так предсказуемее: клиент держит базу у себя и заливает заново. Подмешивание копило бы номера, от которых он не смог бы избавиться — построчного удаления в интерфейсе нет. На экране это написано до нажатия, молчаливой потери нет. - файл без единого годного номера отклоняется, старая база остаётся цела. Иначе клиент залил бы файл не того формата и потерял всё. - замена сделана удалением и вставкой, а не upsert: право UPDATE на таблице не выдано, upsert упёрся бы в это на бою и молча правил бы ноль строк. - наружу отдаём только счётчики. Номера — персональные данные, экрану они не нужны и в ответах не появляются. Приёмка: 12 тестов сервера и 9 тестов экрана, все до кода и все красные по верной причине — сервер отвечал 405, блока на экране не было. Замеры: телеграм-модуль 325 тестов 0 падений, экраны 244 файла 1855 тестов 0 падений, статанализ 0, форматтер чисто. Проверка типов 6 ошибок, все чужие, столько же было до работы. Для выката, проверить на бою: право USAGE на счётчике client_tg_contacts_id_seq. Замерил в тестовой базе — счётчик без права, но ровно так же выглядит и счётчик client_tg_campaigns_id_seq, в который портал на бою пишет. То есть новых прав эта работа не требует, но проверка дешёвая, а пропущенный grant на бою даёт отказ, которого на dev не видно. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\ClientTg;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ClientTg\TelegramBazaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* «Моя база номеров» клиента: показать размер, залить файл, очистить.
|
||||
*
|
||||
* 🔴 Номера — персональные данные. Наружу отдаём ТОЛЬКО счётчики: сколько принято,
|
||||
* сколько повторов, сколько не похоже на номер. Сами номера не возвращаем и в логи
|
||||
* не пишем — экрану они не нужны, а утечь могут.
|
||||
*/
|
||||
final class ContactController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TelegramBazaService $baza) {}
|
||||
|
||||
/** Сколько номеров в базе — этим экран решает, показывать ли пункт «Моя база». */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(['count' => $this->baza->skolko($this->tenantId($request))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заливка файла: разбираем, потом ЗАМЕНЯЕМ базу.
|
||||
*
|
||||
* 🪤 Ни одного годного номера — отказ 422 и старая база остаётся нетронутой.
|
||||
* Иначе клиент залил бы файл не того формата и молча потерял всё, что было.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(
|
||||
['file' => 'required|file|max:10240'],
|
||||
[
|
||||
'file.required' => 'Выберите файл с номерами.',
|
||||
'file.max' => 'Файл больше 10 МБ. Разбейте базу на части.',
|
||||
],
|
||||
);
|
||||
|
||||
$put = $request->file('file')->getRealPath();
|
||||
$razbor = $this->baza->razobrat($put === false ? '' : (string) file_get_contents($put));
|
||||
|
||||
if ($razbor['phones'] === []) {
|
||||
return response()->json([
|
||||
'message' => 'В файле не нашлось ни одного номера. Нужен обычный список: '.
|
||||
'по одному номеру в строке или номер первым столбцом таблицы.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stalo = $this->baza->zamenit($this->tenantId($request), $razbor['phones']);
|
||||
|
||||
return response()->json([
|
||||
'count' => $stalo,
|
||||
'accepted' => $stalo,
|
||||
'duplicates' => $razbor['duplicates'],
|
||||
'rejected' => $razbor['rejected'],
|
||||
// Файл длиннее потолка — говорим прямо, а не молча берём первые сколько-то.
|
||||
'oborvano' => $razbor['oborvano'],
|
||||
'max' => TelegramBazaService::MAX_NOMEROV,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Очистка базы целиком. */
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$this->baza->ochistit($this->tenantId($request));
|
||||
|
||||
return response()->json(['count' => 0]);
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): int
|
||||
{
|
||||
return (int) $request->user()->tenant_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientTg;
|
||||
|
||||
use App\Models\ClientTg\Contact;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* «Моя база номеров»: разбор залитого файла и замена базы тенанта.
|
||||
*
|
||||
* 🔴 Почему это вообще понадобилось. Пункт «Моя база номеров» жил на экране с
|
||||
* 27.07.2026, читающая половина была готова (TelegramAudienceService::fromBase), а
|
||||
* писать в `client_tg_contacts` не умела ни одна строка кода. У любого клиента пункт
|
||||
* всегда показывал ноль.
|
||||
*
|
||||
* 🔴 Загрузка ЗАМЕНЯЕТ базу целиком, а не подмешивает. Так предсказуемее: клиент
|
||||
* держит базу в своей таблице и заливает её заново. Подмешивание копило бы в портале
|
||||
* номера, которых у клиента давно нет, и он не смог бы от них избавиться — прав на
|
||||
* построчное удаление в интерфейсе нет.
|
||||
*
|
||||
* 🪤 GRANT на таблице — SELECT, INSERT, DELETE, БЕЗ UPDATE. Поэтому замена сделана
|
||||
* удалением и вставкой, а не upsert'ом: upsert упёрся бы в право на бою и молча
|
||||
* правил бы ноль строк.
|
||||
*/
|
||||
final class TelegramBazaService
|
||||
{
|
||||
/**
|
||||
* Потолок номеров в одной заливке.
|
||||
*
|
||||
* Не про производительность, а про честность: файл больше этого почти наверняка
|
||||
* не база клиента, а выгрузка «всего подряд». Показов столько никто не оплатит.
|
||||
*/
|
||||
public const MAX_NOMEROV = 200000;
|
||||
|
||||
/** Вставляем пачками — один INSERT на 200 тысяч строк ложится в память драйвера. */
|
||||
private const PACHKA = 1000;
|
||||
|
||||
/**
|
||||
* Разбирает содержимое файла: номер на строку либо первый столбец таблицы.
|
||||
*
|
||||
* Нераспознанное не роняет разбор — клиент заливает выгрузку с шапкой и лишними
|
||||
* столбцами, и упасть на первой же строке «телефон» значит не принять ничего.
|
||||
*
|
||||
* @return array{phones: list<string>, duplicates: int, rejected: int, oborvano: bool}
|
||||
*/
|
||||
public function razobrat(string $soderzhimoe): array
|
||||
{
|
||||
$phones = [];
|
||||
$vidennye = [];
|
||||
$duplicates = 0;
|
||||
$rejected = 0;
|
||||
$oborvano = false;
|
||||
|
||||
foreach (preg_split('/\R/u', $soderzhimoe) ?: [] as $stroka) {
|
||||
$stroka = trim($stroka);
|
||||
if ($stroka === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Таблицу отдают и с запятой, и с точкой с запятой, и с табуляцией —
|
||||
// номер везде идёт первым столбцом.
|
||||
$pervyyStolbets = trim((string) (preg_split('/[;,\t]/', $stroka)[0] ?? ''));
|
||||
|
||||
$norm = PhoneNormalizer::normalize($pervyyStolbets);
|
||||
if ($norm === null) {
|
||||
$rejected++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$goliy = substr($norm, 1); // «+7…» → «7…», как хранит таблица и ждёт читающая половина
|
||||
if (isset($vidennye[$goliy])) {
|
||||
$duplicates++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$vidennye[$goliy] = true;
|
||||
$phones[] = $goliy;
|
||||
|
||||
if (count($phones) >= self::MAX_NOMEROV) {
|
||||
$oborvano = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'phones' => $phones,
|
||||
'duplicates' => $duplicates,
|
||||
'rejected' => $rejected,
|
||||
'oborvano' => $oborvano,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменяет базу тенанта на переданный список. Возвращает, сколько номеров в ней стало.
|
||||
*
|
||||
* @param list<string> $phones голые 11-значные номера
|
||||
*/
|
||||
public function zamenit(int $tenantId, array $phones): int
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $phones): int {
|
||||
// RLS-контекст сервис-слоя — как в TelegramAudienceService. Явный where по
|
||||
// tenant_id ниже не лишний: боевой воркер ходит под ролью с BYPASSRLS.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
Contact::where('tenant_id', $tenantId)->delete();
|
||||
|
||||
$seychas = now();
|
||||
foreach (array_chunk($phones, self::PACHKA) as $pachka) {
|
||||
Contact::insert(array_map(fn (string $phone): array => [
|
||||
'tenant_id' => $tenantId,
|
||||
'phone' => $phone,
|
||||
'created_at' => $seychas,
|
||||
'updated_at' => $seychas,
|
||||
], $pachka));
|
||||
}
|
||||
|
||||
return count($phones);
|
||||
});
|
||||
}
|
||||
|
||||
/** Очищает базу тенанта целиком. */
|
||||
public function ochistit(int $tenantId): void
|
||||
{
|
||||
DB::transaction(function () use ($tenantId): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
Contact::where('tenant_id', $tenantId)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/** Сколько номеров сейчас в базе тенанта. */
|
||||
public function skolko(int $tenantId): int
|
||||
{
|
||||
return Contact::where('tenant_id', $tenantId)->count();
|
||||
}
|
||||
}
|
||||
@@ -2916,6 +2916,54 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/AutoSafeguardTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 12
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeaders\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/BazaNomerovTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
|
||||
@@ -227,3 +227,50 @@ export async function uploadAutoRuleMedia(file: File): Promise<TelegramAutoRule>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
createTelegram,
|
||||
launchTelegram,
|
||||
uploadTelegramMedia,
|
||||
fetchTgBaza,
|
||||
uploadTgBaza,
|
||||
clearTgBaza,
|
||||
type TgBazaItog,
|
||||
MEDIA_ACCEPT,
|
||||
MEDIA_TREBOVANIYA,
|
||||
MEDIA_CENA,
|
||||
@@ -57,6 +61,68 @@ const audienceKind = ref<TelegramAudienceKind>('deals');
|
||||
const audienceDays = ref('30');
|
||||
const phonesText = ref('');
|
||||
|
||||
// ——— «Моя база номеров» ———
|
||||
//
|
||||
// 🔴 До 02.08.2026 этот пункт вёл в никуда: писать в таблицу не умела ни одна строка
|
||||
// кода, и у любого клиента он всегда показывал ноль. Клиент выбирал «свою базу»,
|
||||
// видел пустоту и решал, что портал потерял его клиентов.
|
||||
const bazaCount = ref<number | null>(null);
|
||||
const bazaFile = ref<File | File[] | null>(null);
|
||||
const bazaIdet = ref(false);
|
||||
const bazaItog = ref<TgBazaItog | null>(null);
|
||||
const bazaOshibka = ref('');
|
||||
|
||||
async function obnovitRazmerBazy(): Promise<void> {
|
||||
try {
|
||||
bazaCount.value = (await fetchTgBaza()).count;
|
||||
} catch {
|
||||
// Размер базы — не повод рушить весь шаг: аудиторию можно выбрать и другую.
|
||||
bazaCount.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Заливка файла. Итог показываем целиком — сколько принято, сколько повторов, сколько
|
||||
* не разобрали: «принято 1200» без остального читается как «файл зашёл полностью».
|
||||
*/
|
||||
async function zalitBazu(fajly: File[] | File | null): Promise<void> {
|
||||
const fajl = Array.isArray(fajly) ? fajly[0] : fajly;
|
||||
if (!fajl) {
|
||||
return;
|
||||
}
|
||||
|
||||
bazaIdet.value = true;
|
||||
bazaOshibka.value = '';
|
||||
bazaItog.value = null;
|
||||
try {
|
||||
const itog = await uploadTgBaza(fajl);
|
||||
bazaItog.value = itog;
|
||||
bazaCount.value = itog.count;
|
||||
// Аудитория изменилась — счётчик охвата обязан пересчитаться, иначе он покажет
|
||||
// охват прежней базы.
|
||||
void obnovitOhvat();
|
||||
} catch (e: unknown) {
|
||||
const otvet = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
bazaOshibka.value = otvet ?? 'Не удалось загрузить файл. Попробуйте ещё раз.';
|
||||
} finally {
|
||||
bazaIdet.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ochistitBazu(): Promise<void> {
|
||||
bazaIdet.value = true;
|
||||
bazaOshibka.value = '';
|
||||
bazaItog.value = null;
|
||||
try {
|
||||
bazaCount.value = (await clearTgBaza()).count;
|
||||
void obnovitOhvat();
|
||||
} catch {
|
||||
bazaOshibka.value = 'Не удалось очистить базу. Попробуйте ещё раз.';
|
||||
} finally {
|
||||
bazaIdet.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ——— Шаг 2: объявление ———
|
||||
const adText = ref('');
|
||||
const adLink = ref('');
|
||||
@@ -164,8 +230,22 @@ function otlozhennyyPereschyot(): void {
|
||||
|
||||
watch([audienceKind, audienceDays, phonesText, mediaFile], otlozhennyyPereschyot);
|
||||
|
||||
// Размер базы спрашиваем ровно тогда, когда клиент на неё смотрит: остальным
|
||||
// источникам аудитории он не нужен, а лишний запрос на каждом входе в мастер — нужен
|
||||
// ещё меньше.
|
||||
watch(audienceKind, (kind) => {
|
||||
if (kind === 'base') {
|
||||
bazaItog.value = null;
|
||||
bazaOshibka.value = '';
|
||||
void obnovitRazmerBazy();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void obnovitOhvat();
|
||||
if (audienceKind.value === 'base') {
|
||||
void obnovitRazmerBazy();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -321,6 +401,66 @@ defineExpose({ step, obnovitOhvat });
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<!--
|
||||
«Моя база номеров». До 02.08.2026 пункт был, а заливки не было —
|
||||
клиент видел ноль и решал, что портал потерял его клиентов.
|
||||
-->
|
||||
<v-card
|
||||
v-if="audienceKind === 'base'"
|
||||
variant="tonal"
|
||||
class="pa-3 mb-2"
|
||||
data-testid="tg-baza"
|
||||
>
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<span v-if="bazaCount === null || bazaCount === 0" class="text-body-2" data-testid="tg-baza-count">
|
||||
Номеров пока нет — загрузите файл со своей базой.
|
||||
</span>
|
||||
<span v-else class="text-body-2" data-testid="tg-baza-count">
|
||||
В вашей базе <strong class="num">{{ bazaCount }}</strong> номеров.
|
||||
</span>
|
||||
<v-progress-circular v-if="bazaIdet" indeterminate color="primary" size="18" />
|
||||
</div>
|
||||
|
||||
<v-file-input
|
||||
v-model="bazaFile"
|
||||
data-testid="tg-baza-file"
|
||||
label="Файл с номерами"
|
||||
accept=".csv,.txt,text/csv,text/plain"
|
||||
hint="По одному номеру в строке или номер первым столбцом таблицы. Новая загрузка заменит базу целиком."
|
||||
persistent-hint
|
||||
prepend-icon="mdi-file-upload-outline"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
show-size
|
||||
:disabled="bazaIdet"
|
||||
@update:model-value="zalitBazu"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center ga-3 mt-2">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
data-testid="tg-baza-clear"
|
||||
:disabled="bazaIdet"
|
||||
@click="ochistitBazu"
|
||||
>
|
||||
Очистить базу
|
||||
</v-btn>
|
||||
<span v-if="bazaItog" class="text-body-2 text-medium-emphasis" data-testid="tg-baza-itog">
|
||||
Принято {{ bazaItog.accepted }};
|
||||
повторов {{ bazaItog.duplicates }};
|
||||
не похоже на номер {{ bazaItog.rejected }}.
|
||||
<template v-if="bazaItog.oborvano">
|
||||
Файл длиннее потолка — взяли первые {{ bazaItog.max }}.
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="bazaOshibka" class="text-body-2 text-error mt-2" data-testid="tg-baza-error">
|
||||
{{ bazaOshibka }}
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-textarea
|
||||
v-if="audienceKind === 'list'"
|
||||
v-model="phonesText"
|
||||
|
||||
@@ -109,6 +109,13 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(fu
|
||||
Route::post('/campaigns/{id}/resubmit', 'App\Http\Controllers\Api\ClientTg\CampaignController@resubmit')->whereNumber('id');
|
||||
Route::post('/campaigns/{id}/media', 'App\Http\Controllers\Api\ClientTg\CampaignController@attachMedia')->whereNumber('id');
|
||||
|
||||
// «Моя база номеров» (client_tg_contacts): показать размер, залить файл, очистить.
|
||||
// Пункт «Моя база» жил на экране с 27.07.2026, а писать в таблицу не умела ни одна
|
||||
// строка кода — у любого клиента он всегда показывал ноль.
|
||||
Route::get('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@index');
|
||||
Route::post('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@store');
|
||||
Route::delete('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@destroy');
|
||||
|
||||
// Правило авто-рекламы (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
|
||||
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
|
||||
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientTg\Contact;
|
||||
use App\Models\ClientTg\Optout;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
/**
|
||||
* «Моя база номеров» — загрузка, замена и очистка.
|
||||
*
|
||||
* 🔴 Зачем появилось. Пункт «Моя база номеров» стоял на экране с 27.07.2026, а писать
|
||||
* в таблицу `client_tg_contacts` НЕ УМЕЛА ни одна строка кода: ни экрана загрузки, ни
|
||||
* серверной ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
|
||||
* Читающая половина при этом была готова с самого начала —
|
||||
* `TelegramAudienceService::fromBase` умеет и нормализацию, и дубли, и стоп-лист.
|
||||
*
|
||||
* 🪤 Ловушка, из-за которой это не поймали 292 зелёных теста: прежние тесты «своей базы»
|
||||
* САМИ вписывали контакты через Contact::create и потому доходили до состояния, до
|
||||
* которого клиент дойти не мог. Проверяя источник данных, спрашивать надо не «читается
|
||||
* ли он», а «кто в него пишет».
|
||||
*
|
||||
* Номера 7999… синтетические — настоящих ПДн в тестах нет.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['Accept' => 'application/json']);
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/** Файл базы — как его выгружает обычная таблица: по номеру на строку. */
|
||||
function faylBazy(string $soderzhimoe, string $imya = 'baza.csv'): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent($imya, $soderzhimoe);
|
||||
}
|
||||
|
||||
it('пустая база честно говорит про ноль, а не молчит', function () {
|
||||
$this->getJson('/api/telegram/contacts')
|
||||
->assertOk()
|
||||
->assertJson(['count' => 0]);
|
||||
});
|
||||
|
||||
it('загруженные номера ложатся в базу и она перестаёт быть пустой', function () {
|
||||
$this->post('/api/telegram/contacts', [
|
||||
'file' => faylBazy("79990000001\n79990000002\n79990000003\n"),
|
||||
])->assertOk()->assertJson(['count' => 3, 'accepted' => 3]);
|
||||
|
||||
expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('номера приводятся к единому виду — как их ни записали в файле', function () {
|
||||
$this->post('/api/telegram/contacts', [
|
||||
'file' => faylBazy("+7 999 000-00-01\n8(999)0000002\n9990000003\n"),
|
||||
])->assertOk()->assertJson(['accepted' => 3]);
|
||||
|
||||
// Все три легли одинаково — иначе один и тот же человек попал бы в базу трижды.
|
||||
expect(Contact::where('tenant_id', $this->tenant->id)->pluck('phone')->sort()->values()->all())
|
||||
->toBe(['79990000001', '79990000002', '79990000003']);
|
||||
});
|
||||
|
||||
it('повторы внутри файла схлопываются и считаются отдельно', function () {
|
||||
$this->post('/api/telegram/contacts', [
|
||||
'file' => faylBazy("79990000001\n8 999 000 00 01\n79990000002\n"),
|
||||
])->assertOk()->assertJson(['count' => 2, 'accepted' => 2, 'duplicates' => 1]);
|
||||
});
|
||||
|
||||
it('мусорные строки не роняют загрузку, а попадают в счётчик негодных', function () {
|
||||
$this->post('/api/telegram/contacts', [
|
||||
'file' => faylBazy("телефон\n79990000001\nне номер\n123\n79990000002\n"),
|
||||
])->assertOk()->assertJson(['count' => 2, 'accepted' => 2, 'rejected' => 3]);
|
||||
});
|
||||
|
||||
it('вторая загрузка ЗАМЕНЯЕТ базу, а не подмешивает к старой', function () {
|
||||
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n79990000002\n")])->assertOk();
|
||||
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000009\n")])->assertOk()
|
||||
->assertJson(['count' => 1]);
|
||||
|
||||
expect(Contact::where('tenant_id', $this->tenant->id)->pluck('phone')->all())->toBe(['79990000009']);
|
||||
});
|
||||
|
||||
it('базу можно очистить целиком', function () {
|
||||
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk();
|
||||
|
||||
$this->deleteJson('/api/telegram/contacts')->assertOk()->assertJson(['count' => 0]);
|
||||
|
||||
expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('файл совсем без годных номеров отклоняется, а старая база остаётся цела', function () {
|
||||
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk();
|
||||
|
||||
$this->post('/api/telegram/contacts', ['file' => faylBazy("шапка\nне номер\n")])
|
||||
->assertStatus(422);
|
||||
|
||||
// Иначе клиент залил бы кривой файл и молча потерял всю базу.
|
||||
expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('чужую базу не видно и не тронуть', function () {
|
||||
$chuzhoy = Tenant::factory()->create();
|
||||
Contact::create(['tenant_id' => $chuzhoy->id, 'phone' => '79995550001']);
|
||||
|
||||
$this->getJson('/api/telegram/contacts')->assertOk()->assertJson(['count' => 0]);
|
||||
|
||||
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk();
|
||||
|
||||
// Замена базы своего тенанта не должна была снести чужие строки.
|
||||
expect(Contact::where('tenant_id', $chuzhoy->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('после загрузки охват по своей базе наконец считается — ради этого всё и делалось', function () {
|
||||
$this->post('/api/telegram/contacts', [
|
||||
'file' => faylBazy("79990000001\n79990000002\n79990000003\n"),
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/api/telegram/campaigns/estimate', ['audience_kind' => 'base'])
|
||||
->assertOk()
|
||||
->assertJson(['planned_count' => 3]);
|
||||
});
|
||||
|
||||
it('стоп-лист вычитается из базы — помеченный номер рекламу не увидит', function () {
|
||||
Optout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002']);
|
||||
|
||||
$this->post('/api/telegram/contacts', [
|
||||
'file' => faylBazy("79990000001\n79990000002\n79990000003\n"),
|
||||
])->assertOk()->assertJson(['count' => 3]);
|
||||
|
||||
// В базе номер лежит, но в аудиторию не попадает — стоп-лист сильнее.
|
||||
$this->postJson('/api/telegram/campaigns/estimate', ['audience_kind' => 'base'])
|
||||
->assertOk()
|
||||
->assertJson(['planned_count' => 2]);
|
||||
});
|
||||
|
||||
it('без входа в портал база недоступна', function () {
|
||||
app('auth')->forgetGuards();
|
||||
|
||||
$this->postJson('/api/telegram/contacts', [])->assertStatus(401);
|
||||
$this->getJson('/api/telegram/contacts')->assertStatus(401);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
/**
|
||||
* «Моя база номеров» на экране мастера: сколько номеров, заливка файла, очистка.
|
||||
*
|
||||
* 🔴 Пункт «Моя база номеров» стоял в выборе аудитории с 27.07.2026 и вёл в никуда:
|
||||
* писать в таблицу не умела ни одна строка кода, у любого клиента там был ноль.
|
||||
* Клиент выбирал «свою базу», видел ноль и решал, что портал потерял его клиентов.
|
||||
* Из-за этого выкат телеграм-рекламы был заблокирован.
|
||||
*
|
||||
* 🪤 Заливка ЗАМЕНЯЕТ базу целиком. Это должно быть написано на экране: молчаливая
|
||||
* замена — это потеря данных, о которой клиент узнаёт после.
|
||||
*/
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
}));
|
||||
|
||||
const { estimateMock, fetchBazaMock, uploadBazaMock, clearBazaMock } = vi.hoisted(() => ({
|
||||
estimateMock: vi.fn(),
|
||||
fetchBazaMock: vi.fn(),
|
||||
uploadBazaMock: vi.fn(),
|
||||
clearBazaMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/telegram', () => ({
|
||||
estimateTelegram: estimateMock,
|
||||
fetchTgBaza: fetchBazaMock,
|
||||
uploadTgBaza: uploadBazaMock,
|
||||
clearTgBaza: clearBazaMock,
|
||||
createTelegram: vi.fn(),
|
||||
launchTelegram: vi.fn(),
|
||||
uploadTelegramMedia: vi.fn(),
|
||||
fetchTelegram: vi.fn(),
|
||||
fetchAutoRule: vi.fn(),
|
||||
resubmitTelegram: vi.fn(),
|
||||
uploadAutoRuleMedia: vi.fn(),
|
||||
saveAutoRule: vi.fn(),
|
||||
MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4',
|
||||
MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880.',
|
||||
MEDIA_CENA: 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.',
|
||||
}));
|
||||
|
||||
import TelegramCampaignWizard from '../../resources/js/views/advertising/TelegramCampaignWizard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
type Wrapper = ReturnType<typeof mount>;
|
||||
|
||||
function smontirovat(): Wrapper {
|
||||
return mount(TelegramCampaignWizard, {
|
||||
props: { balanceRub: '5000.00' },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
}
|
||||
|
||||
/** Выбрать «Моя база номеров» — тот же путь, которым идёт клиент. */
|
||||
async function vybratBazu(w: Wrapper): Promise<void> {
|
||||
await w.find('[data-testid="tg-audience-base"] input').setValue(true);
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
async function zalitFayl(w: Wrapper, imya = 'baza.csv'): Promise<void> {
|
||||
const file = new File(['79990000001\n79990000002\n'], imya, { type: 'text/csv' });
|
||||
const pole = w.find('[data-testid="tg-baza-file"] input[type="file"]');
|
||||
Object.defineProperty(pole.element, 'files', { value: [file] });
|
||||
await pole.trigger('change');
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
describe('моя база номеров', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
estimateMock.mockResolvedValue({
|
||||
planned_count: 512,
|
||||
estimated_cost_rub: '430.08',
|
||||
min_count: 367,
|
||||
enough: true,
|
||||
});
|
||||
fetchBazaMock.mockResolvedValue({ count: 0 });
|
||||
uploadBazaMock.mockResolvedValue({
|
||||
count: 2,
|
||||
accepted: 2,
|
||||
duplicates: 0,
|
||||
rejected: 0,
|
||||
oborvano: false,
|
||||
max: 200000,
|
||||
});
|
||||
clearBazaMock.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
it('пустая база говорит об этом прямо, а не показывает молчаливый ноль', async () => {
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
|
||||
const baza = w.find('[data-testid="tg-baza"]');
|
||||
expect(baza.exists()).toBe(true);
|
||||
expect(baza.text()).toContain('Номеров пока нет');
|
||||
});
|
||||
|
||||
it('когда номера есть — на экране их число', async () => {
|
||||
fetchBazaMock.mockResolvedValue({ count: 1240 });
|
||||
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
|
||||
expect(w.find('[data-testid="tg-baza-count"]').text()).toContain('1240');
|
||||
});
|
||||
|
||||
it('на экране написано, что заливка заменит базу — молчаливой потери быть не должно', async () => {
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
|
||||
expect(w.find('[data-testid="tg-baza"]').text()).toContain('заменит');
|
||||
});
|
||||
|
||||
it('выбранный файл уходит на сервер', async () => {
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
await zalitFayl(w);
|
||||
|
||||
expect(uploadBazaMock).toHaveBeenCalledTimes(1);
|
||||
expect((uploadBazaMock.mock.calls[0][0] as File).name).toBe('baza.csv');
|
||||
});
|
||||
|
||||
it('после заливки видно, сколько номеров принято', async () => {
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
await zalitFayl(w);
|
||||
|
||||
expect(w.find('[data-testid="tg-baza-itog"]').text()).toContain('2');
|
||||
expect(w.find('[data-testid="tg-baza-count"]').text()).toContain('2');
|
||||
});
|
||||
|
||||
it('повторы и негодные строки не прячутся — клиент видит, что не всё вошло', async () => {
|
||||
uploadBazaMock.mockResolvedValue({
|
||||
count: 2,
|
||||
accepted: 2,
|
||||
duplicates: 3,
|
||||
rejected: 5,
|
||||
oborvano: false,
|
||||
max: 200000,
|
||||
});
|
||||
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
await zalitFayl(w);
|
||||
|
||||
const itog = w.find('[data-testid="tg-baza-itog"]').text();
|
||||
expect(itog).toContain('3');
|
||||
expect(itog).toContain('5');
|
||||
});
|
||||
|
||||
it('отказ сервера объясняется словами, а не пропадает', async () => {
|
||||
uploadBazaMock.mockRejectedValue({
|
||||
response: { data: { message: 'В файле не нашлось ни одного номера.' } },
|
||||
});
|
||||
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
await zalitFayl(w);
|
||||
|
||||
expect(w.find('[data-testid="tg-baza-error"]').text()).toContain('не нашлось ни одного номера');
|
||||
});
|
||||
|
||||
it('базу можно очистить, и число на экране становится нулём', async () => {
|
||||
fetchBazaMock.mockResolvedValue({ count: 1240 });
|
||||
|
||||
const w = smontirovat();
|
||||
await vybratBazu(w);
|
||||
await w.find('[data-testid="tg-baza-clear"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(clearBazaMock).toHaveBeenCalledTimes(1);
|
||||
expect(w.find('[data-testid="tg-baza"]').text()).toContain('Номеров пока нет');
|
||||
});
|
||||
|
||||
it('на других источниках аудитории блока базы нет — он не про них', async () => {
|
||||
const w = smontirovat();
|
||||
await flushPromises();
|
||||
|
||||
await w.find('[data-testid="tg-audience-list"] input').setValue(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(w.find('[data-testid="tg-baza"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user