feat(кошелёк): две колонки, период, разрез по кампаниям, расшифровка заморозки
Владелец 06.08: "кошелёк убогий и неинформативный". Отметил пять слабых мест из шести. Не отметил только прогноз "надолго ли хватит" - он пополняет по факту, а не по остатку. Что теперь на экране: - слева липкая колонка денег: свободно крупно, и под ней РАСШИФРОВКА заморозки - какая кампания, сколько заперто, когда вернётся. Раньше стояло голое "Заморожено 2 507 руб" без единого слова, под что; - период: сегодня / вчера / 7 дней / 30 дней / свои даты, живёт в адресе страницы; - столбики расхода по дням, цвет по каналу, своим CSS без библиотеки; - таблица "Куда ушли" - строка на кампанию с суммой и долей, клик отбирает ленту; - закладки по каналам сохранены, но теперь считаются за выбранный период; - лента разбита по дням с итогом за день и постраничной догрузкой. Сервер: две новые сборки RaskryitieZamorozki и OtchyotKoshelka, новая ручка /api/advertising/wallet/report, лента получила отбор по датам, каналу и кампании и листание по ключу вместо смещения. Расчёт списаний, заморозки и возврата по сроку НЕ тронут - правка только про показ. Найдено по дороге и закрыто: - чтение отчёта обёрнуто контекстом построчной защиты. Без этого на боевом запрос вернул бы НОЛЬ строк молча: местная база под суперпользователем защиту обходит, и мы бы увидели это только у клиента; - сторож на число запросов мог зеленеть БЕЗ самой ручки - на любой неизвестный путь портал отвечает страницей с кодом 200. Усилен и проверен вырезанием маршрута; - общий форматтер срезал хвостовой ноль: "833,50" превращалось в "833,5". На это независимо наступили три правки подряд, каждая завела свою копию. Сведено в formatExact. Границы суток и группировка по дням считаются ПО МОСКВЕ, а не по Гринвичу. Сторож взят с живого боевого: запись 69, 2026-08-05 21:00:24 UTC - для человека это 6 августа. Наивные версии всех трёх мест были написаны нарочно и увидены красными, прежде чем чинились. Старые сторожа не выброшены, а перенесены: закладки по каналам переехали в отчёт вместе с проверкой на 120 строк, отбор ленты по каналу - в проверку ленты. Каждый переезд назван поимённо. Проверено: 461 сторож сервера, 2135 сторожей экрана, контроль типов ноль, статанализ ноль, форматтер чисто, сборка фронта проходит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Services\Advertising\AdWalletGate;
|
||||
use App\Services\Advertising\OpisanieDvizheniyaKoshelka;
|
||||
use App\Services\Advertising\OtchyotKoshelka;
|
||||
use App\Services\Advertising\RaskryitieZamorozki;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -21,7 +24,7 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class AdvertisingWalletController extends Controller
|
||||
{
|
||||
public function show(Request $request, AdWalletGate $gate): JsonResponse
|
||||
public function show(Request $request, AdWalletGate $gate, RaskryitieZamorozki $raskryitie): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
@@ -34,39 +37,60 @@ class AdvertisingWalletController extends Controller
|
||||
'balance_rub' => $balanceRub,
|
||||
'frozen_rub' => $frozenRub,
|
||||
'free_rub' => bcsub($balanceRub, $frozenRub, 2),
|
||||
// Под что именно заперты деньги и когда вернутся — иначе число «заморожено»
|
||||
// остаётся необъяснимым, как было до 06.08.2026.
|
||||
'frozen_breakdown' => $raskryitie->dlyaTenanta($tenantId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* История движений рекламного кошелька — то, чего клиенту не хватало, чтобы объяснить
|
||||
* себе заморозку (приёмка 03.08.2026: на экране стояло «История операций появится позже»,
|
||||
* и три тысячи заперты без объяснения).
|
||||
* Лента движений кошелька: свежие сверху, страницами по 50.
|
||||
*
|
||||
* Отдаём последние 100 движений, свежие сверху. Изоляция клиентов — той же защитой по
|
||||
* строкам, что и у остальных денежных путей: middleware `tenant` ставит контекст, а
|
||||
* запрос дополнительно ограничен своим tenant_id.
|
||||
* Отбор: `from`/`to` — московские сутки включительно; `channel`; пара
|
||||
* `source_type` + `source_id` — конкретная кампания. Постраничность — по ключу
|
||||
* (`before_id`), а не по смещению: летопись пополняется каждый час, и `offset`
|
||||
* начал бы задваивать строки прямо во время листания.
|
||||
*
|
||||
* Закладки по каналам (`channels`) — ответ на вопрос владельца 06.08.2026: «сколько
|
||||
* человек тратит на какую рекламу». Кошелёк один на все каналы, и в общей ленте
|
||||
* Яндекс, СМС и Телеграм идут вперемешку.
|
||||
* Закладки каналов и итог трат отсюда УШЛИ — они живут в отчёте за период
|
||||
* (`/wallet/report`), где согласованы с выбранными датами.
|
||||
*
|
||||
* 🔴 Суммы трат считаются по ВСЕЙ летописи, а не по сотне отдаваемых строк. Иначе у
|
||||
* давнего канала цифра тихо «усохла» бы ровно тогда, когда лента переполнится.
|
||||
* Необязательный `?channel=` фильтрует ЛЕНТУ, но не суммы — закладка с давними
|
||||
* движениями обязана показывать свою настоящую сумму, даже когда её строки уже
|
||||
* не попадают в сотню свежих.
|
||||
* Порядок — `id desc`, а не `created_at desc, id desc`: листание по ключу требует
|
||||
* ОДНОГО монотонного столбца, иначе строки одной секунды перескакивали бы между
|
||||
* страницами. `id` растёт вместе со временем, порядок на экране не меняется.
|
||||
*/
|
||||
public function transactions(Request $request, OpisanieDvizheniyaKoshelka $opisanie): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$kanalFiltra = $request->query('channel');
|
||||
$kanalFiltra = is_string($kanalFiltra) && $kanalFiltra !== '' ? $kanalFiltra : null;
|
||||
$dannye = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:from'],
|
||||
'channel' => ['nullable', 'string', 'max:32'],
|
||||
'source_type' => ['nullable', 'string', 'max:64'],
|
||||
'source_id' => ['nullable', 'integer'],
|
||||
'before_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$dvizheniya = AdWalletTransaction::where('tenant_id', $tenantId)
|
||||
->when($kanalFiltra !== null, fn ($q) => $q->where('channel', $kanalFiltra))
|
||||
->orderByDesc('created_at')->orderByDesc('id')
|
||||
->limit(100)
|
||||
->get();
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$razmer = 50;
|
||||
|
||||
$zapros = AdWalletTransaction::where('tenant_id', $tenantId)
|
||||
->when(($dannye['channel'] ?? '') !== '', fn ($q) => $q->where('channel', $dannye['channel']))
|
||||
->when(($dannye['source_type'] ?? '') !== '' && ($dannye['source_id'] ?? null) !== null,
|
||||
fn ($q) => $q->where('related_type', $dannye['source_type'])
|
||||
->where('related_id', (int) $dannye['source_id']))
|
||||
->when(($dannye['before_id'] ?? null) !== null, fn ($q) => $q->where('id', '<', (int) $dannye['before_id']));
|
||||
|
||||
// Даты — московские сутки, переведённые в UTC-окно. Тот же расчёт, что в отчёте.
|
||||
if (($dannye['from'] ?? '') !== '') {
|
||||
$zapros->where('created_at', '>=', CarbonImmutable::parse($dannye['from'], 'Europe/Moscow')->startOfDay()->utc());
|
||||
}
|
||||
if (($dannye['to'] ?? '') !== '') {
|
||||
$zapros->where('created_at', '<', CarbonImmutable::parse($dannye['to'], 'Europe/Moscow')->addDay()->startOfDay()->utc());
|
||||
}
|
||||
|
||||
// Берём на одну больше запрошенного — так узнаём про «ещё» без второго запроса.
|
||||
$dvizheniya = $zapros->orderByDesc('id')->limit($razmer + 1)->get();
|
||||
$eshchyo = $dvizheniya->count() > $razmer;
|
||||
$dvizheniya = $dvizheniya->take($razmer);
|
||||
|
||||
// Подпись собираем ЗДЕСЬ, а не берём из поля `description`: в летописи уже лежат
|
||||
// строки, записанные немой фразой «Списание за рекламу (факт)», и переписывать
|
||||
@@ -92,62 +116,38 @@ class AdvertisingWalletController extends Controller
|
||||
'description' => $podpisi[(int) $t->id] ?? (string) ($t->description ?? ''),
|
||||
'created_at' => $t->created_at->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
[$zakladki, $vsegoPotracheno] = $this->tratyPoKanalam($tenantId);
|
||||
|
||||
return response()->json([
|
||||
'items' => $items,
|
||||
'channels' => $zakladki,
|
||||
'spent_total_rub' => $vsegoPotracheno,
|
||||
'has_more' => $eshchyo,
|
||||
'next_before_id' => $eshchyo && $items !== [] ? $items[count($items) - 1]['id'] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Траты по каналам за всё время — два запроса на всю ленту, без обращения к базе
|
||||
* внутри перебора.
|
||||
* Отчёт за период: сколько ушло по дням, по кампаниям и по каналам.
|
||||
*
|
||||
* Расход — это ТОЛЬКО списание. Заморозка деньги не тратит (их ещё можно вернуть,
|
||||
* что и случилось с 724 ₽ по кампании №13), пополнение и возврат — тем более.
|
||||
*
|
||||
* @return array{0: list<array{code: string, title: string, spent_rub: string}>, 1: string}
|
||||
* Даты — МОСКОВСКИЕ, включительно с обоих концов; сервер сам переводит их в UTC-окно.
|
||||
* По умолчанию — последние 30 дней по Москве.
|
||||
*/
|
||||
private function tratyPoKanalam(int $tenantId): array
|
||||
public function report(Request $request, OtchyotKoshelka $otchyot): JsonResponse
|
||||
{
|
||||
$summy = AdWalletTransaction::where('tenant_id', $tenantId)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)
|
||||
->whereNotNull('channel')
|
||||
->selectRaw('channel, sum(amount_rub) as summa')
|
||||
->groupBy('channel')
|
||||
->pluck('summa', 'channel');
|
||||
$segodnya = CarbonImmutable::now('Europe/Moscow');
|
||||
$dannye = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:from'],
|
||||
'channel' => ['nullable', 'string', 'max:32'],
|
||||
]);
|
||||
|
||||
// Закладку заводим каждому каналу, где ХОТЬ ЧТО-ТО происходило, даже если потрачено
|
||||
// пока ноль: у канала могут быть только заморозки — клиенту это тоже надо видеть.
|
||||
$kody = AdWalletTransaction::where('tenant_id', $tenantId)
|
||||
->whereNotNull('channel')
|
||||
->distinct()
|
||||
->pluck('channel')
|
||||
->map(fn ($k): string => (string) $k)
|
||||
->all();
|
||||
$do = $dannye['to'] ?? $segodnya->format('Y-m-d');
|
||||
$ot = $dannye['from'] ?? $segodnya->subDays(29)->format('Y-m-d');
|
||||
$kanal = ($dannye['channel'] ?? '') !== '' ? $dannye['channel'] : null;
|
||||
|
||||
$zakladki = [];
|
||||
$vsego = '0.00';
|
||||
foreach ($kody as $kod) {
|
||||
// Списания лежат со знаком минус — трата это модуль суммы.
|
||||
$potracheno = bcsub('0.00', (string) ($summy[$kod] ?? '0'), 2);
|
||||
$vsego = bcadd($vsego, $potracheno, 2);
|
||||
$zakladki[] = [
|
||||
'code' => $kod,
|
||||
'title' => OpisanieDvizheniyaKoshelka::nazvanieKanala($kod),
|
||||
'spent_rub' => $potracheno,
|
||||
];
|
||||
}
|
||||
|
||||
// Больше тратишь — выше стоишь; при равенстве порядок стабильный, по названию.
|
||||
usort($zakladki, fn (array $a, array $b): int => bccomp($b['spent_rub'], $a['spent_rub'], 2)
|
||||
?: strcmp($a['title'], $b['title']));
|
||||
|
||||
return [$zakladki, $vsego];
|
||||
return response()->json(
|
||||
$otchyot->sobrat((int) $request->user()->tenant_id, $ot, $do, $kanal)
|
||||
);
|
||||
}
|
||||
|
||||
/** Человеческая подпись типа движения — клиент не должен читать слово «freeze». */
|
||||
|
||||
@@ -105,6 +105,44 @@ final class OpisanieDvizheniyaKoshelka
|
||||
return implode(' · ', $kusochki);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписи для произвольных пар «канал + вид владельца + номер» — заморозке и таблице
|
||||
* кампаний нужен тот же сборщик, что и ленте, но входа-летописи у них нет.
|
||||
*
|
||||
* @param list<array{channel: string, source_type: string, source_id: int}> $pary
|
||||
* @return array<string, string> «канал:вид:номер» → подпись
|
||||
*/
|
||||
public function dlyaVladeltsev(array $pary): array
|
||||
{
|
||||
$nomera = [];
|
||||
foreach ($pary as $p) {
|
||||
$nomera[$this->klyuchVida($p['source_type'], $p['channel'])][] = $p['source_id'];
|
||||
}
|
||||
|
||||
$imena = [];
|
||||
foreach ($nomera as $klyuch => $spisok) {
|
||||
$imena[$klyuch] = $this->nazvaniya($klyuch, array_values(array_unique($spisok)));
|
||||
}
|
||||
|
||||
$gotovo = [];
|
||||
foreach ($pary as $p) {
|
||||
$tip = $p['source_type'];
|
||||
$nazvanie = $imena[$this->klyuchVida($tip, $p['channel'])][$p['source_id']] ?? null;
|
||||
$chto = self::CHTO[$tip] ?? $tip;
|
||||
$gotovo[$p['channel'].':'.$tip.':'.$p['source_id']] = $nazvanie === null
|
||||
? $chto.' №'.$p['source_id']
|
||||
: $chto.' №'.$p['source_id'].' «'.$this->obrezat($nazvanie).'»';
|
||||
}
|
||||
|
||||
return $gotovo;
|
||||
}
|
||||
|
||||
/** У СМС и Яндекса вид владельца один ('campaign'), а таблицы разные — различаем каналом. */
|
||||
private function klyuchVida(string $tip, string $kanal): string
|
||||
{
|
||||
return $tip === 'campaign' ? 'campaign:'.$kanal : $tip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Имена владельцев одним запросом на вид. Ключ — «вид:номер», чтобы номера разных
|
||||
* видов не перемешались.
|
||||
@@ -122,7 +160,7 @@ final class OpisanieDvizheniyaKoshelka
|
||||
}
|
||||
// У СМС и у Яндекса вид владельца один и тот же — 'campaign', а таблицы разные.
|
||||
// Различаем по каналу, иначе имя приедет из чужой таблицы с тем же номером.
|
||||
$klyuch = $tip === 'campaign' ? 'campaign:'.((string) ($t->channel ?? '')) : $tip;
|
||||
$klyuch = $this->klyuchVida($tip, (string) ($t->channel ?? ''));
|
||||
$nomera[$klyuch][] = (int) $t->related_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Advertising;
|
||||
|
||||
use App\Models\AdWalletTransaction;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Отчёт по рекламному кошельку за период: по дням, по кампаниям, по каналам.
|
||||
*
|
||||
* 🔴 Пояс. Приложение живёт в UTC, столбец `created_at` — `timestamp without time zone`,
|
||||
* а кабинет показывает МОСКОВСКОЕ время. Значит и границы периода, и группировка по дням
|
||||
* считаются по Москве. Живой пример с боевого: запись #69, `2026-08-05 21:00:24` UTC —
|
||||
* это 6 августа для человека. Наивная группировка отнесла бы её к 5-му.
|
||||
*
|
||||
* 🔑 Расход — это ТОЛЬКО списание. Заморозка деньги не тратит (их возвращают, как 724 ₽
|
||||
* по кампании №13), пополнение и возврат — тем более.
|
||||
*
|
||||
* `ad_wallet_transactions` под RLS (FORCE ROW LEVEL SECURITY) — читаем внутри транзакции
|
||||
* с SET LOCAL контекста тенанта, как BillingSummaryProvider.
|
||||
*/
|
||||
final class OtchyotKoshelka
|
||||
{
|
||||
private const POYAS = 'Europe/Moscow';
|
||||
|
||||
public function __construct(private readonly OpisanieDvizheniyaKoshelka $opisanie) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* from: string,
|
||||
* to: string,
|
||||
* spent_total_rub: string,
|
||||
* by_day: list<array{day: string, spent_rub: string, by_channel: array<string, string>}>,
|
||||
* by_campaign: list<array{channel: string, channel_title: string, source_type: string, source_id: int, title: string, spent_rub: string, share_percent: float}>,
|
||||
* channels: list<array{code: string, title: string, spent_rub: string}>,
|
||||
* }
|
||||
*/
|
||||
public function sobrat(int $tenantId, string $ot, string $do, ?string $kanal = null): array
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $ot, $do, $kanal): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
[$nachalo, $konec] = $this->granicy($ot, $do);
|
||||
|
||||
$poDnyam = $this->poDnyam($tenantId, $nachalo, $konec, $kanal, $ot, $do);
|
||||
$poKampaniyam = $this->poKampaniyam($tenantId, $nachalo, $konec, $kanal);
|
||||
$poKanalam = $this->poKanalam($tenantId, $nachalo, $konec);
|
||||
|
||||
$vsego = '0.00';
|
||||
foreach ($poDnyam as $den) {
|
||||
$vsego = bcadd($vsego, $den['spent_rub'], 2);
|
||||
}
|
||||
|
||||
return [
|
||||
'from' => $ot,
|
||||
'to' => $do,
|
||||
'spent_total_rub' => $vsego,
|
||||
'by_day' => $poDnyam,
|
||||
'by_campaign' => $this->sDolyami($poKampaniyam, $vsego),
|
||||
'channels' => $poKanalam,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Московские сутки → границы в UTC: [ot 00:00 МСК, do+1 00:00 МСК).
|
||||
*
|
||||
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
|
||||
*/
|
||||
private function granicy(string $ot, string $do): array
|
||||
{
|
||||
return [
|
||||
CarbonImmutable::parse($ot, self::POYAS)->startOfDay()->utc(),
|
||||
CarbonImmutable::parse($do, self::POYAS)->addDay()->startOfDay()->utc(),
|
||||
];
|
||||
}
|
||||
|
||||
private function zapros(int $tenantId, CarbonImmutable $nachalo, CarbonImmutable $konec)
|
||||
{
|
||||
return AdWalletTransaction::where('tenant_id', $tenantId)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)
|
||||
->where('created_at', '>=', $nachalo)
|
||||
->where('created_at', '<', $konec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Все дни периода, включая нулевые: иначе в графике будут дырки вместо провалов.
|
||||
*
|
||||
* @return list<array{day: string, spent_rub: string, by_channel: array<string, string>}>
|
||||
*/
|
||||
private function poDnyam(int $tenantId, CarbonImmutable $nachalo, CarbonImmutable $konec,
|
||||
?string $kanal, string $ot, string $do): array
|
||||
{
|
||||
$syrye = $this->zapros($tenantId, $nachalo, $konec)
|
||||
->when($kanal !== null, fn ($q) => $q->where('channel', $kanal))
|
||||
->selectRaw("to_char((created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') as den")
|
||||
->selectRaw('channel, sum(amount_rub) as summa')
|
||||
->groupBy('den', 'channel')
|
||||
->get();
|
||||
|
||||
$poDnyam = [];
|
||||
foreach ($syrye as $r) {
|
||||
$den = (string) $r->den;
|
||||
$poDnyam[$den][(string) $r->channel] = bcsub('0.00', (string) $r->summa, 2);
|
||||
}
|
||||
|
||||
$gotovo = [];
|
||||
$tekushchiy = CarbonImmutable::parse($ot, self::POYAS);
|
||||
$posledniy = CarbonImmutable::parse($do, self::POYAS);
|
||||
while ($tekushchiy <= $posledniy) {
|
||||
$den = $tekushchiy->format('Y-m-d');
|
||||
$poKanalam = $poDnyam[$den] ?? [];
|
||||
$itog = '0.00';
|
||||
foreach ($poKanalam as $summa) {
|
||||
$itog = bcadd($itog, $summa, 2);
|
||||
}
|
||||
$gotovo[] = ['day' => $den, 'spent_rub' => $itog, 'by_channel' => $poKanalam];
|
||||
$tekushchiy = $tekushchiy->addDay();
|
||||
}
|
||||
|
||||
return $gotovo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{channel: string, channel_title: string, source_type: string, source_id: int, title: string, spent_rub: string, share_percent: float}>
|
||||
*/
|
||||
private function poKampaniyam(int $tenantId, CarbonImmutable $nachalo, CarbonImmutable $konec, ?string $kanal): array
|
||||
{
|
||||
$syrye = $this->zapros($tenantId, $nachalo, $konec)
|
||||
->when($kanal !== null, fn ($q) => $q->where('channel', $kanal))
|
||||
->whereNotNull('related_id')
|
||||
->selectRaw('channel, related_type, related_id, sum(amount_rub) as summa')
|
||||
->groupBy('channel', 'related_type', 'related_id')
|
||||
->get();
|
||||
|
||||
if ($syrye->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pary = $syrye->map(fn ($r): array => [
|
||||
'channel' => (string) $r->channel,
|
||||
'source_type' => (string) $r->related_type,
|
||||
'source_id' => (int) $r->related_id,
|
||||
])->all();
|
||||
$podpisi = $this->opisanie->dlyaVladeltsev($pary);
|
||||
|
||||
$stroki = $syrye->map(fn ($r): array => [
|
||||
'channel' => (string) $r->channel,
|
||||
'channel_title' => OpisanieDvizheniyaKoshelka::nazvanieKanala((string) $r->channel),
|
||||
'source_type' => (string) $r->related_type,
|
||||
'source_id' => (int) $r->related_id,
|
||||
'title' => $podpisi[$r->channel.':'.$r->related_type.':'.$r->related_id] ?? '',
|
||||
'spent_rub' => bcsub('0.00', (string) $r->summa, 2),
|
||||
'share_percent' => 0.0,
|
||||
])->all();
|
||||
|
||||
usort($stroki, fn (array $a, array $b): int => bccomp($b['spent_rub'], $a['spent_rub'], 2)
|
||||
?: strcmp($a['title'], $b['title']));
|
||||
|
||||
return $stroki;
|
||||
}
|
||||
|
||||
/**
|
||||
* Закладки каналов — по ВЫБРАННОМУ периоду и БЕЗ отбора по каналу: иначе выбранная
|
||||
* закладка съест соседние и переключиться станет некуда.
|
||||
*
|
||||
* @return list<array{code: string, title: string, spent_rub: string}>
|
||||
*/
|
||||
private function poKanalam(int $tenantId, CarbonImmutable $nachalo, CarbonImmutable $konec): array
|
||||
{
|
||||
$syrye = $this->zapros($tenantId, $nachalo, $konec)
|
||||
->whereNotNull('channel')
|
||||
->selectRaw('channel, sum(amount_rub) as summa')
|
||||
->groupBy('channel')
|
||||
->get();
|
||||
|
||||
$stroki = $syrye->map(fn ($r): array => [
|
||||
'code' => (string) $r->channel,
|
||||
'title' => OpisanieDvizheniyaKoshelka::nazvanieKanala((string) $r->channel),
|
||||
'spent_rub' => bcsub('0.00', (string) $r->summa, 2),
|
||||
])->all();
|
||||
|
||||
usort($stroki, fn (array $a, array $b): int => bccomp($b['spent_rub'], $a['spent_rub'], 2)
|
||||
?: strcmp($a['title'], $b['title']));
|
||||
|
||||
return $stroki;
|
||||
}
|
||||
|
||||
/**
|
||||
* Доля от итога — для полоски в таблице. Итог ноль → доли нулевые, без деления на ноль.
|
||||
*
|
||||
* @param list<array{channel: string, channel_title: string, source_type: string, source_id: int, title: string, spent_rub: string, share_percent: float}> $stroki
|
||||
* @return list<array{channel: string, channel_title: string, source_type: string, source_id: int, title: string, spent_rub: string, share_percent: float}>
|
||||
*/
|
||||
private function sDolyami(array $stroki, string $vsego): array
|
||||
{
|
||||
if (bccomp($vsego, '0.00', 2) === 0) {
|
||||
return $stroki;
|
||||
}
|
||||
|
||||
foreach ($stroki as $i => $s) {
|
||||
$stroki[$i]['share_percent'] = round((float) $s['spent_rub'] / (float) $vsego * 100, 1);
|
||||
}
|
||||
|
||||
return $stroki;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Advertising;
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\AdWalletHold;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Расшифровка заморозки: под что именно заперты деньги и когда вернутся.
|
||||
*
|
||||
* 🔴 Зачем. Владелец, 06.08.2026: «Заморожено 2 507 ₽» стояло без единого слова, под что.
|
||||
* Чтобы понять, надо было листать ленту и складывать в уме.
|
||||
*
|
||||
* 🔑 Сумма строк обязана сходиться с `ad_wallets.frozen_rub` — на боевом 06.08 сошлась
|
||||
* копейка в копейку (2 507,00 ₽ на три кампании). Если разойдётся, виновата не эта
|
||||
* расшифровка, а расчёт заморозки: искать там, не подгонять здесь.
|
||||
*/
|
||||
final class RaskryitieZamorozki
|
||||
{
|
||||
public function __construct(private readonly OpisanieDvizheniyaKoshelka $opisanie) {}
|
||||
|
||||
/**
|
||||
* @return list<array{channel: string, channel_title: string, source_type: string,
|
||||
* source_id: int, title: string, amount_rub: string, returns_on: string|null}>
|
||||
*/
|
||||
public function dlyaTenanta(int $tenantId): array
|
||||
{
|
||||
$zamorozki = AdWalletHold::where('tenant_id', $tenantId)
|
||||
->where('status', AdWalletHold::STATUS_ACTIVE)
|
||||
->orderByDesc('amount_rub')->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($zamorozki->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pary = $zamorozki->map(fn (AdWalletHold $z): array => [
|
||||
'channel' => (string) $z->channel,
|
||||
'source_type' => (string) $z->source_type,
|
||||
'source_id' => (int) $z->source_id,
|
||||
])->all();
|
||||
$podpisi = $this->opisanie->dlyaVladeltsev($pary);
|
||||
$sroki = $this->sroki($zamorozki);
|
||||
|
||||
return $zamorozki->map(fn (AdWalletHold $z): array => [
|
||||
'channel' => (string) $z->channel,
|
||||
'channel_title' => OpisanieDvizheniyaKoshelka::nazvanieKanala((string) $z->channel),
|
||||
'source_type' => (string) $z->source_type,
|
||||
'source_id' => (int) $z->source_id,
|
||||
'title' => $podpisi[$z->channel.':'.$z->source_type.':'.$z->source_id] ?? '',
|
||||
'amount_rub' => (string) $z->amount_rub,
|
||||
'returns_on' => $sroki[(int) $z->source_id] ?? null,
|
||||
])->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Сроки показа — одним запросом на все рекламные кампании сразу. Срок есть только у
|
||||
* Яндекса: у СМС и Телеграма заморозка снимается по факту отправки, а не по дате.
|
||||
*
|
||||
* @param Collection<int, AdWalletHold> $zamorozki
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function sroki($zamorozki): array
|
||||
{
|
||||
$ids = $zamorozki
|
||||
->filter(fn (AdWalletHold $z): bool => $z->channel === 'yandex' && $z->source_type === 'campaign')
|
||||
->pluck('source_id')->map(fn ($v): int => (int) $v)->unique()->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AdCampaign::whereIn('id', $ids)
|
||||
->whereNotNull('shows_until')
|
||||
->pluck('shows_until', 'id')
|
||||
->map(fn ($v): string => $v instanceof \DateTimeInterface ? $v->format('Y-m-d') : (string) $v)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,25 @@ export type CampaignStatus =
|
||||
| 'stopped_no_funds'
|
||||
| 'completed';
|
||||
|
||||
/** Строка расшифровки заморозки: под что заперто и когда вернётся. */
|
||||
export interface AdWalletFrozenRow {
|
||||
channel: string;
|
||||
channel_title: string;
|
||||
source_type: string;
|
||||
source_id: number;
|
||||
title: string;
|
||||
amount_rub: string;
|
||||
returns_on: string | null;
|
||||
}
|
||||
|
||||
/** Ответ GET /api/advertising/wallet — статус рекламного кошелька тенанта. */
|
||||
export interface AdWalletStatus {
|
||||
solvent: boolean;
|
||||
balance_rub: string;
|
||||
frozen_rub: string;
|
||||
free_rub: string;
|
||||
/** Под что именно заперты деньги. Пусто — заморозок нет. */
|
||||
frozen_breakdown: AdWalletFrozenRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,26 +209,91 @@ export interface AdWalletChannelTab {
|
||||
spent_rub: string;
|
||||
}
|
||||
|
||||
/** Ответ ручки истории: лента + закладки по каналам + общая трата. */
|
||||
/** Ответ ленты: страница движений + признак «есть ещё» и ключ следующей страницы. */
|
||||
export interface AdWalletHistory {
|
||||
items: AdWalletTransaction[];
|
||||
channels: AdWalletChannelTab[];
|
||||
spent_total_rub: string;
|
||||
has_more: boolean;
|
||||
next_before_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/advertising/wallet/transactions — история движений кошелька, свежие сверху.
|
||||
* `channel` отбирает ленту по каналу (закладка); суммы в `channels` от отбора не зависят.
|
||||
*/
|
||||
export async function fetchWalletTransactions(channel: string | null = null): Promise<AdWalletHistory> {
|
||||
/** День в графике расхода. `by_channel` — коды каналов и потраченное за этот день. */
|
||||
export interface AdWalletDay {
|
||||
day: string;
|
||||
spent_rub: string;
|
||||
by_channel: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Строка таблицы «Куда ушли». */
|
||||
export interface AdWalletCampaignRow {
|
||||
channel: string;
|
||||
channel_title: string;
|
||||
source_type: string;
|
||||
source_id: number;
|
||||
title: string;
|
||||
spent_rub: string;
|
||||
share_percent: number;
|
||||
}
|
||||
|
||||
/** Ответ GET /api/advertising/wallet/report — всё, что зависит от выбранного периода. */
|
||||
export interface AdWalletReport {
|
||||
from: string;
|
||||
to: string;
|
||||
spent_total_rub: string;
|
||||
by_day: AdWalletDay[];
|
||||
by_campaign: AdWalletCampaignRow[];
|
||||
channels: AdWalletChannelTab[];
|
||||
}
|
||||
|
||||
/** Отбор ленты: даты московские, кампания — пара «вид + номер». */
|
||||
export interface AdWalletLedgerFilter {
|
||||
from?: string;
|
||||
to?: string;
|
||||
channel?: string | null;
|
||||
source_type?: string | null;
|
||||
source_id?: number | null;
|
||||
before_id?: number | null;
|
||||
}
|
||||
|
||||
/** GET /api/advertising/wallet/report — отчёт за период. Даты — «ГГГГ-ММ-ДД» по Москве. */
|
||||
export async function fetchWalletReport(
|
||||
from: string,
|
||||
to: string,
|
||||
channel: string | null = null,
|
||||
): Promise<AdWalletReport> {
|
||||
const { data } = await apiClient.get<AdWalletReport>('/api/advertising/wallet/report', {
|
||||
params: channel ? { from, to, channel } : { from, to },
|
||||
});
|
||||
|
||||
return {
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
spent_total_rub: data.spent_total_rub ?? '0.00',
|
||||
by_day: data.by_day ?? [],
|
||||
by_campaign: data.by_campaign ?? [],
|
||||
channels: data.channels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/advertising/wallet/transactions — страница ленты, свежие сверху. */
|
||||
export async function fetchWalletTransactions(filter: AdWalletLedgerFilter = {}): Promise<AdWalletHistory> {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (filter.from) params.from = filter.from;
|
||||
if (filter.to) params.to = filter.to;
|
||||
if (filter.channel) params.channel = filter.channel;
|
||||
if (filter.source_type && filter.source_id) {
|
||||
params.source_type = filter.source_type;
|
||||
params.source_id = filter.source_id;
|
||||
}
|
||||
if (filter.before_id) params.before_id = filter.before_id;
|
||||
|
||||
const { data } = await apiClient.get<AdWalletHistory>('/api/advertising/wallet/transactions', {
|
||||
params: channel ? { channel } : undefined,
|
||||
params: Object.keys(params).length > 0 ? params : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
items: data.items ?? [],
|
||||
channels: data.channels ?? [],
|
||||
spent_total_rub: data.spent_total_rub ?? '0.00',
|
||||
has_more: data.has_more ?? false,
|
||||
next_before_id: data.next_before_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* «Куда ушли» — сколько съела каждая кампания за выбранный период.
|
||||
*
|
||||
* 🔴 Владелец, 06.08.2026: закладки делят расход по каналам, но внутри канала кампании
|
||||
* свалены в кучу — «сколько мне обошлась Я4» приходилось складывать глазами по строчкам
|
||||
* ленты. Строка на кампанию, дорогие сверху, с долей.
|
||||
*
|
||||
* Клик по строке отбирает ленту по этой кампании; повторный клик отбор снимает.
|
||||
*
|
||||
* Деньги — фиксированные 2 знака, не formatPlain (variable decimals режет «126.00» до
|
||||
* «126»): та же развилка построчных сумм, что в AdWalletMoneyColumn.vue::formatRowMoney
|
||||
* (см. её комментарий и ChargesTab.vue::rubFmt, SalesInvoicesView.vue, AdminSmsView.vue).
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import type { AdWalletCampaignRow } from '../../api/advertising';
|
||||
import { formatExact } from '../../composables/billingFormatters';
|
||||
|
||||
const props = defineProps<{ rows: AdWalletCampaignRow[]; selected: AdWalletCampaignRow | null }>();
|
||||
const emit = defineEmits<{ select: [AdWalletCampaignRow | null] }>();
|
||||
|
||||
const PERVYE = 10;
|
||||
const vseVidny = ref(false);
|
||||
|
||||
/** Построчные суммы — всегда с копейками; общий форматтер портала. */
|
||||
function formatRowMoney(value: string): string {
|
||||
return formatExact(Number(value));
|
||||
}
|
||||
|
||||
const otsortirovannye = computed(() =>
|
||||
[...props.rows].sort((a, b) => Number(b.spent_rub) - Number(a.spent_rub)),
|
||||
);
|
||||
|
||||
const vidnye = computed(() =>
|
||||
vseVidny.value ? otsortirovannye.value : otsortirovannye.value.slice(0, PERVYE),
|
||||
);
|
||||
|
||||
function klyuch(r: AdWalletCampaignRow): string {
|
||||
return `${r.channel}:${r.source_type}:${r.source_id}`;
|
||||
}
|
||||
|
||||
function vybrana(r: AdWalletCampaignRow): boolean {
|
||||
return props.selected !== null && klyuch(props.selected) === klyuch(r);
|
||||
}
|
||||
|
||||
function nazhali(r: AdWalletCampaignRow): void {
|
||||
emit('select', vybrana(r) ? null : r);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kuda" data-testid="ad-wallet-campaigns">
|
||||
<div class="zagolovok">Куда ушли</div>
|
||||
|
||||
<p v-if="rows.length === 0" class="podpis mb-0" data-testid="ad-wallet-campaign-empty">
|
||||
За выбранные дни ни одна кампания денег не потратила.
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-for="r in vidnye"
|
||||
:key="klyuch(r)"
|
||||
type="button"
|
||||
class="stroka"
|
||||
:class="{ vybrana: vybrana(r) }"
|
||||
data-testid="ad-wallet-campaign-row"
|
||||
@click="nazhali(r)"
|
||||
>
|
||||
<div class="verh">
|
||||
<span class="nazvanie">{{ r.title }}</span>
|
||||
<span class="num">{{ formatRowMoney(r.spent_rub) }}</span>
|
||||
</div>
|
||||
<div class="niz">
|
||||
<span class="podpis">{{ r.channel_title }}</span>
|
||||
<span class="podpis num">{{ r.share_percent.toLocaleString('ru-RU') }} %</span>
|
||||
</div>
|
||||
<div class="poloska"><div class="zalivka" :style="{ width: r.share_percent + '%' }" /></div>
|
||||
</button>
|
||||
|
||||
<v-btn
|
||||
v-if="rows.length > PERVYE && !vseVidny"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="mt-1"
|
||||
data-testid="ad-wallet-campaign-more"
|
||||
@click="vseVidny = true"
|
||||
>
|
||||
Показать все ({{ rows.length }})
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zagolovok {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.podpis {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stroka {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.stroka:hover {
|
||||
background: rgb(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.stroka.vybrana {
|
||||
background: rgb(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
.verh {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.niz {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nazvanie {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.poloska {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgb(var(--v-border-color), 0.16);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.zalivka {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stroka {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Лента движений, разбитая по МОСКОВСКИМ дням, с итогом за день.
|
||||
*
|
||||
* 🔴 Владелец, 06.08.2026: «лента — бесконечный список без итогов». Через полгода работы
|
||||
* такой лентой пользоваться будет нельзя.
|
||||
*
|
||||
* В итог дня идёт только то, что реально двигает баланс: заморозка и снятие резерва
|
||||
* баланс не трогают, и складывать их с тратами — врать человеку.
|
||||
*
|
||||
* Дни режем по МОСКОВСКОМУ времени через toLocaleDateString('en-CA', {timeZone}) —
|
||||
* движение в 21:00 по Гринвичу уже относится к следующему московскому дню (наивная
|
||||
* нарезка по created_at.slice(0,10) режет по гринвичской дате и путает дни).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import type { AdWalletTransaction } from '../../api/advertising';
|
||||
import { formatExact } from '../../composables/billingFormatters';
|
||||
|
||||
const props = defineProps<{
|
||||
items: AdWalletTransaction[];
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
defineEmits<{ more: [] }>();
|
||||
|
||||
/**
|
||||
* Построчные и итоговые суммы — всегда с копейками. `formatPlain` число знаков не
|
||||
* фиксирует и обрезает «5.00» до «5», что для точных сумм — вранье.
|
||||
*/
|
||||
function formatMoney(value: number): string {
|
||||
return formatExact(value);
|
||||
}
|
||||
|
||||
/** Московская дата движения «ГГГГ-ММ-ДД» — по ней и режем ленту на дни. */
|
||||
function moskovskiyDen(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
|
||||
return new Date(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/Moscow' });
|
||||
}
|
||||
|
||||
const dni = computed(() => {
|
||||
const poDnyam = new Map<string, AdWalletTransaction[]>();
|
||||
for (const t of props.items) {
|
||||
const den = moskovskiyDen(t.created_at);
|
||||
if (!poDnyam.has(den)) poDnyam.set(den, []);
|
||||
poDnyam.get(den)!.push(t);
|
||||
}
|
||||
|
||||
return [...poDnyam.entries()].map(([den, stroki]) => ({
|
||||
den,
|
||||
nazvanie: new Date(den + 'T00:00:00+03:00').toLocaleDateString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow', day: 'numeric', month: 'long',
|
||||
}),
|
||||
itog: stroki.reduce((s, t) => (t.moves_balance ? s + Number(t.amount_rub) : s), 0),
|
||||
stroki,
|
||||
}));
|
||||
});
|
||||
|
||||
function vremya(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
|
||||
return new Date(iso).toLocaleTimeString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function znak(value: string): string {
|
||||
const n = Number(value);
|
||||
|
||||
return (n < 0 ? '−' : n > 0 ? '+' : '') + formatMoney(Math.abs(n));
|
||||
}
|
||||
|
||||
function znakItoga(itog: number): string {
|
||||
return (itog < 0 ? '−' : itog > 0 ? '+' : '') + formatMoney(Math.abs(itog));
|
||||
}
|
||||
|
||||
function cvetSummy(t: AdWalletTransaction): string {
|
||||
if (!t.moves_balance) return 'text-medium-emphasis';
|
||||
|
||||
return Number(t.amount_rub) < 0 ? 'text-error' : 'text-success';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lenta" data-testid="ad-wallet-ledger">
|
||||
<div class="zagolovok">Все движения</div>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center py-4">
|
||||
<v-progress-circular indeterminate color="primary" size="24" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="items.length === 0"
|
||||
class="podpis mb-0"
|
||||
data-testid="ad-wallet-ledger-empty"
|
||||
>
|
||||
За выбранные дни движений по кошельку не было.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<section v-for="d in dni" :key="d.den" class="den" data-testid="ad-wallet-ledger-day">
|
||||
<header class="shapka-dnya">
|
||||
<span>{{ d.nazvanie }}</span>
|
||||
<span class="num" data-testid="ad-wallet-ledger-day-total">
|
||||
{{ znakItoga(d.itog) }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-for="t in d.stroki"
|
||||
:key="t.id"
|
||||
class="stroka"
|
||||
data-testid="ad-wallet-ledger-row"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-2">{{ t.title }}</div>
|
||||
<div v-if="t.description" class="podpis">{{ t.description }}</div>
|
||||
<div class="podpis">{{ vremya(t.created_at) }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="num" :class="cvetSummy(t)">{{ znak(t.amount_rub) }}</div>
|
||||
<div v-if="!t.moves_balance" class="podpis">баланс не изменился</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-btn
|
||||
v-if="hasMore"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
data-testid="ad-wallet-ledger-more"
|
||||
@click="$emit('more')"
|
||||
>
|
||||
Показать ещё
|
||||
</v-btn>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zagolovok {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.podpis {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.den + .den {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.shapka-dnya {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.stroka {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stroka + .stroka {
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Левая колонка экрана «Рекламный кошелёк»: деньги и расшифровка заморозки.
|
||||
*
|
||||
* 🔴 Владелец, 06.08.2026: «Заморожено 2 507 ₽» стояло без единого слова, под что. Теперь
|
||||
* под суммой — строки: какая кампания, сколько заперто, когда вернётся.
|
||||
*
|
||||
* Тёмная карточка — единственное тёмное пятно на странице: деньги должны читаться первыми.
|
||||
* Диалог пополнения держит страница, колонка только просит его открыть.
|
||||
*/
|
||||
import type { AdWalletStatus } from '../../api/advertising';
|
||||
import { formatExact, formatPlain } from '../../composables/billingFormatters';
|
||||
|
||||
defineProps<{ wallet: AdWalletStatus }>();
|
||||
defineEmits<{ topup: [] }>();
|
||||
|
||||
function formatMoney(value: string): string {
|
||||
return formatPlain(Number(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Заморозка под конкретную кампанию — построчная точная сумма, а не круглый итог.
|
||||
* formatPlain (variable decimals) обрезает «833.50» до «833,5» — верно для заголовочных
|
||||
* сумм (баланс/заперто/свободно), но не для построчных денег. Та же развилка, что уже
|
||||
* есть в портале: заголовки через formatPlain, построчные суммы — фиксированные 2 знака
|
||||
* (см. ChargesTab.vue::rubFmt, SalesInvoicesView.vue, AdminSmsView.vue).
|
||||
*/
|
||||
/** Построчные суммы заморозки — всегда с копейками; общий форматтер портала. */
|
||||
function formatRowMoney(value: string): string {
|
||||
return formatExact(Number(value));
|
||||
}
|
||||
|
||||
/** «2026-08-07» → «вернётся 7 августа». Без срока — честное «срок не задан». */
|
||||
function srok(returnsOn: string | null): string {
|
||||
if (!returnsOn) return 'срок не задан';
|
||||
|
||||
const d = new Date(returnsOn + 'T00:00:00+03:00');
|
||||
|
||||
return 'вернётся ' + d.toLocaleDateString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow', day: 'numeric', month: 'long',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kolonka-deneg" data-testid="ad-wallet-money-column">
|
||||
<v-alert
|
||||
v-if="!wallet.solvent"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
class="mb-3"
|
||||
icon="mdi-alert-circle-outline"
|
||||
data-testid="ad-wallet-insolvent-alert"
|
||||
>
|
||||
Пополните рекламный кошелёк — реклама остановлена.
|
||||
</v-alert>
|
||||
|
||||
<div class="karta">
|
||||
<div class="podpis">Свободно</div>
|
||||
<div class="glavnaya num" data-testid="ad-wallet-free">{{ formatMoney(wallet.free_rub) }}</div>
|
||||
|
||||
<div class="melko">
|
||||
Всего <span class="num" data-testid="ad-wallet-balance">{{ formatMoney(wallet.balance_rub) }}</span>
|
||||
· заперто <span class="num" data-testid="ad-wallet-frozen">{{ formatMoney(wallet.frozen_rub) }}</span>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mt-4"
|
||||
data-testid="ad-wallet-topup-btn"
|
||||
@click="$emit('topup')"
|
||||
>
|
||||
Пополнить
|
||||
</v-btn>
|
||||
|
||||
<div class="zamorozka">
|
||||
<div class="podpis mb-2">Заперто под</div>
|
||||
|
||||
<p
|
||||
v-if="wallet.frozen_breakdown.length === 0"
|
||||
class="melko mb-0"
|
||||
data-testid="ad-wallet-frozen-empty"
|
||||
>
|
||||
Ничего не заперто, все деньги свободны.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="r in wallet.frozen_breakdown"
|
||||
:key="`${r.channel}:${r.source_type}:${r.source_id}`"
|
||||
class="stroka"
|
||||
data-testid="ad-wallet-frozen-row"
|
||||
>
|
||||
<div class="d-flex justify-space-between ga-2">
|
||||
<span class="nazvanie">{{ r.title }}</span>
|
||||
<span class="num">{{ formatRowMoney(r.amount_rub) }}</span>
|
||||
</div>
|
||||
<div class="melko">{{ r.channel_title }} · {{ srok(r.returns_on) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.karta {
|
||||
background: #012019;
|
||||
color: #f6f3ec;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.podpis {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.glavnaya {
|
||||
font-size: 32px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.melko {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.zamorozka {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(246, 243, 236, 0.15);
|
||||
}
|
||||
|
||||
.stroka + .stroka {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nazvanie {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Выбор периода: «Сегодня · Вчера · 7 дней · 30 дней · Свои даты».
|
||||
*
|
||||
* 🔴 Все даты — МОСКОВСКИЕ. Машина клиента может стоять в любом поясе, а кабинет всегда
|
||||
* показывает московское время. «Сегодня» в 01:00 МСК — уже новый день, хотя по Гринвичу
|
||||
* ещё вчерашний вечер.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{ from: string; to: string }>();
|
||||
const emit = defineEmits<{ change: [{ from: string; to: string }] }>();
|
||||
|
||||
const svoiDaty = ref(false);
|
||||
const svoiOt = ref(props.from);
|
||||
const svoiDo = ref(props.to);
|
||||
|
||||
/** Московская дата «ГГГГ-ММ-ДД» со сдвигом в днях — как её видит человек в кабинете. */
|
||||
function moskovskiyDen(sdvigDney = 0): string {
|
||||
return new Date(Date.now() + sdvigDney * 86400000)
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/Moscow' });
|
||||
}
|
||||
|
||||
const presety = computed(() => [
|
||||
{ kod: 'today', nazvanie: 'Сегодня', ot: moskovskiyDen(0), do: moskovskiyDen(0) },
|
||||
{ kod: 'yesterday', nazvanie: 'Вчера', ot: moskovskiyDen(-1), do: moskovskiyDen(-1) },
|
||||
{ kod: '7', nazvanie: '7 дней', ot: moskovskiyDen(-6), do: moskovskiyDen(0) },
|
||||
{ kod: '30', nazvanie: '30 дней', ot: moskovskiyDen(-29), do: moskovskiyDen(0) },
|
||||
]);
|
||||
|
||||
function vybran(p: { ot: string; do: string }): boolean {
|
||||
return props.from === p.ot && props.to === p.do;
|
||||
}
|
||||
|
||||
function vzyat(p: { ot: string; do: string }): void {
|
||||
svoiDaty.value = false;
|
||||
emit('change', { from: p.ot, to: p.do });
|
||||
}
|
||||
|
||||
function vzyatSvoi(): void {
|
||||
if (!svoiOt.value || !svoiDo.value) return;
|
||||
const [ot, do_] = svoiOt.value <= svoiDo.value
|
||||
? [svoiOt.value, svoiDo.value]
|
||||
: [svoiDo.value, svoiOt.value];
|
||||
emit('change', { from: ot, to: do_ });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="period" data-testid="ad-wallet-period">
|
||||
<button
|
||||
v-for="p in presety"
|
||||
:key="p.kod"
|
||||
type="button"
|
||||
class="knopka"
|
||||
:class="{ vybran: vybran(p) }"
|
||||
:data-testid="`ad-wallet-period-${p.kod}`"
|
||||
@click="vzyat(p)"
|
||||
>
|
||||
{{ p.nazvanie }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="knopka"
|
||||
:class="{ vybran: svoiDaty }"
|
||||
data-testid="ad-wallet-period-custom"
|
||||
@click="svoiDaty = !svoiDaty"
|
||||
>
|
||||
Свои даты
|
||||
</button>
|
||||
|
||||
<div v-if="svoiDaty" class="svoi mt-2" data-testid="ad-wallet-period-custom-form">
|
||||
<input v-model="svoiOt" type="date" class="pole" data-testid="ad-wallet-period-from" >
|
||||
<span class="tire">—</span>
|
||||
<input v-model="svoiDo" type="date" class="pole" data-testid="ad-wallet-period-to" >
|
||||
<v-btn size="small" variant="tonal" data-testid="ad-wallet-period-apply" @click="vzyatSvoi">
|
||||
Показать
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.period {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.knopka {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid rgb(var(--v-border-color), 0.24);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.knopka:hover {
|
||||
border-color: rgb(var(--v-theme-primary), 0.5);
|
||||
}
|
||||
|
||||
.knopka.vybran {
|
||||
background: #012019;
|
||||
color: #f6f3ec;
|
||||
border-color: #012019;
|
||||
}
|
||||
|
||||
.svoi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.pole {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgb(var(--v-border-color), 0.24);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tire {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.knopka {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Расход по дням — столбики. Рисуем своим CSS: ставить графическую библиотеку ради
|
||||
* десятка прямоугольников незачем.
|
||||
*
|
||||
* Высота — доля от САМОГО БОЛЬШОГО дня периода, а не от итога: иначе при одном крупном
|
||||
* дне все остальные схлопнутся в невидимую полоску. Нулевой день оставляем ниточкой в
|
||||
* 2 пикселя — пустое место читается как «данных нет», а не как «в этот день не тратили».
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import type { AdWalletDay } from '../../api/advertising';
|
||||
import { formatExact, formatPlain } from '../../composables/billingFormatters';
|
||||
|
||||
const props = defineProps<{ days: AdWalletDay[]; totalRub: string }>();
|
||||
|
||||
/** Цвет канала. Незнакомому — серый: выдумывать цвет на ходу нельзя, он должен быть один. */
|
||||
const CVETA: Record<string, string> = {
|
||||
yandex: '#0F6E56',
|
||||
sms: '#8a6d3b',
|
||||
telegram: '#2f6f9f',
|
||||
vk: '#4a5fa5',
|
||||
ai_call: '#7a5a8a',
|
||||
};
|
||||
|
||||
const maksimum = computed(() => props.days.reduce(
|
||||
(m, d) => Math.max(m, Number(d.spent_rub)), 0,
|
||||
));
|
||||
|
||||
const pusto = computed(() => maksimum.value === 0);
|
||||
|
||||
function vysota(d: AdWalletDay): string {
|
||||
if (maksimum.value === 0) return '2px';
|
||||
const dolya = Number(d.spent_rub) / maksimum.value;
|
||||
|
||||
return dolya === 0 ? '2px' : `${Math.round(dolya * 100)}%`;
|
||||
}
|
||||
|
||||
/** Столбик красим цветом канала, который в этот день потратил больше всех. */
|
||||
function cvet(d: AdWalletDay): string {
|
||||
let luchshiy = '';
|
||||
let bolshe = -1;
|
||||
for (const [kod, summa] of Object.entries(d.by_channel)) {
|
||||
if (Number(summa) > bolshe) {
|
||||
bolshe = Number(summa);
|
||||
luchshiy = kod;
|
||||
}
|
||||
}
|
||||
|
||||
return CVETA[luchshiy] ?? '#b8b2a6';
|
||||
}
|
||||
|
||||
function podskazka(d: AdWalletDay): string {
|
||||
const den = new Date(d.day + 'T00:00:00+03:00').toLocaleDateString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow', day: 'numeric', month: 'long',
|
||||
});
|
||||
// formatPlain округляет целые рубли без копеек («124 ₽»); в подсказке за день
|
||||
// копейки нужны всегда — сумма мелкая, округление съедает разницу.
|
||||
return `${den}: ${formatExact(Number(d.spent_rub))}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grafik" data-testid="ad-wallet-chart">
|
||||
<div class="shapka">
|
||||
<span class="podpis">Потрачено за период</span>
|
||||
<span class="itog num" data-testid="ad-wallet-chart-total">{{ formatPlain(Number(totalRub)) }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="pusto" class="podpis mb-0 mt-2" data-testid="ad-wallet-chart-empty">
|
||||
За выбранные дни ничего не потрачено.
|
||||
</p>
|
||||
|
||||
<div v-else class="stolbiki mt-3">
|
||||
<div
|
||||
v-for="d in days"
|
||||
:key="d.day"
|
||||
class="stolbik"
|
||||
data-testid="ad-wallet-chart-bar"
|
||||
:style="{ height: vysota(d), background: cvet(d) }"
|
||||
:title="podskazka(d)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shapka {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.podpis {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.itog {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.stolbiki {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.stolbik {
|
||||
flex: 1 1 0;
|
||||
min-width: 4px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,23 @@ export function formatPlain(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
/**
|
||||
* «833.5» → «833,50 ₽» — ВСЕГДА две цифры после запятой.
|
||||
*
|
||||
* 🪤 `formatPlain` число знаков не фиксирует и срезает хвостовой ноль: «833.50» у него
|
||||
* выходит «833,5 ₽», а «124.00» — «124 ₽». В крупных заголовочных суммах это незаметно,
|
||||
* а в построчных деньгах читается как ошибка в копейках. 06.08.2026 на это независимо
|
||||
* наступили три правки кошелька подряд, каждая завела свою копию форматтера — свели сюда.
|
||||
*
|
||||
* Заголовочные суммы (баланс/заперто/свободно) по-прежнему через `formatPlain`.
|
||||
*/
|
||||
export function formatExact(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
/** Знаковый формат: «+ 5 000 ₽» / «− 6 600 ₽» / «0 ₽». */
|
||||
export function formatCost(cost: number): string {
|
||||
const sign = cost > 0 ? '+ ' : cost < 0 ? '− ' : '';
|
||||
|
||||
@@ -1,53 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* «Рекламный кошелёк» — отдельная страница (T19, план 2026-07-25).
|
||||
* «Рекламный кошелёк» — страница. Держит выбранный период, канал и отбор по кампании,
|
||||
* дёргает три ручки и раскладывает две колонки. Своей разметки почти не имеет: всё живёт
|
||||
* в компонентах.
|
||||
*
|
||||
* Один общий баланс для всех рекламных каналов (сейчас — «Яндекс Аудитория»,
|
||||
* дальше — ВК/Телеграм/СМС по мере выхода из заглушки). Данные —
|
||||
* GET /api/advertising/wallet (api/advertising.ts::fetchWallet), тот же
|
||||
* источник, что и у AdWalletHeader (шапка внутри экрана «Яндекс Аудитория»).
|
||||
* 🔴 Три ручки независимы намеренно. Деньги (левая колонка) от периода НЕ зависят и
|
||||
* перечитываются только после пополнения; отчёт и лента — при каждой смене периода,
|
||||
* канала и отбора. Обвал отчёта не должен уносить с экрана баланс.
|
||||
*
|
||||
* История операций — GET /api/advertising/wallet/transactions. До 03.08.2026 здесь стояла
|
||||
* пометка «появится позже», и клиент не мог объяснить себе ни одну заморозку: на боевом у
|
||||
* него были заперты 3 333,36 ₽ без единого слова о том, за что (приёмка Я-Ф1/Я-Д1).
|
||||
* Период живёт в адресе страницы (`?from=…&to=…`): обновление не сбрасывает выбор.
|
||||
*
|
||||
* 🔴 Владелец, 06.08.2026: «раздели списание на закладки, чтобы было чётко видно, сколько
|
||||
* человек тратит на какую рекламу». Раньше закладки жили в ленте и считались за всё
|
||||
* время; теперь берутся из отчёта (report.channels) и считаются за выбранный период —
|
||||
* суммы на закладках при этом НЕ схлопываются отбором по каналу, сервер их и так
|
||||
* считает без учёта отбора.
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
fetchWallet,
|
||||
fetchWalletReport,
|
||||
fetchWalletTransactions,
|
||||
type AdWalletChannelTab,
|
||||
type AdWalletCampaignRow,
|
||||
type AdWalletReport,
|
||||
type AdWalletStatus,
|
||||
type AdWalletTransaction,
|
||||
} from '../../api/advertising';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
import { formatPlain } from '../../composables/billingFormatters';
|
||||
import { formatExact } from '../../composables/billingFormatters';
|
||||
import AdWalletTopupDialog from '../../components/advertising/AdWalletTopupDialog.vue';
|
||||
import AdWalletMoneyColumn from '../../components/advertising/AdWalletMoneyColumn.vue';
|
||||
import AdWalletPeriodPicker from '../../components/advertising/AdWalletPeriodPicker.vue';
|
||||
import AdWalletSpendChart from '../../components/advertising/AdWalletSpendChart.vue';
|
||||
import AdWalletCampaignTable from '../../components/advertising/AdWalletCampaignTable.vue';
|
||||
import AdWalletLedger from '../../components/advertising/AdWalletLedger.vue';
|
||||
|
||||
/** Московская дата «ГГГГ-ММ-ДД» со сдвигом — как её видит человек в кабинете. */
|
||||
function moskovskiyDen(sdvig = 0): string {
|
||||
return new Date(Date.now() + sdvig * 86400000)
|
||||
.toLocaleDateString('en-CA', { timeZone: 'Europe/Moscow' });
|
||||
}
|
||||
|
||||
/** Период из адреса страницы, если он там есть; иначе — последние 30 московских дней. */
|
||||
function nachalnyPeriod(): { from: string; to: string } {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
const from = p.get('from');
|
||||
const to = p.get('to');
|
||||
if (from && to) return { from, to };
|
||||
|
||||
return { from: moskovskiyDen(-29), to: moskovskiyDen(0) };
|
||||
}
|
||||
|
||||
const nachalo = nachalnyPeriod();
|
||||
|
||||
const wallet = ref<AdWalletStatus | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadError = ref<string | null>(null);
|
||||
const showTopupDialog = ref(false);
|
||||
|
||||
const from = ref(nachalo.from);
|
||||
const to = ref(nachalo.to);
|
||||
|
||||
const report = ref<AdWalletReport | null>(null);
|
||||
const reportError = ref<string | null>(null);
|
||||
const activeChannel = ref<string | null>(null);
|
||||
const vybranaKampaniya = ref<AdWalletCampaignRow | null>(null);
|
||||
|
||||
const history = ref<AdWalletTransaction[]>([]);
|
||||
const historyLoading = ref(true);
|
||||
const historyError = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const nextBeforeId = ref<number | null>(null);
|
||||
|
||||
/**
|
||||
* Закладки по каналам — ответ на вопрос владельца 06.08.2026: «сколько человек тратит
|
||||
* на какую рекламу». Кошелёк один на все каналы, и в общей ленте Яндекс, СМС и Телеграм
|
||||
* идут вперемешку.
|
||||
*
|
||||
* Сумма стоит ПРЯМО НА ЗАКЛАДКЕ: главный вопрос должен отвечаться одним взглядом, без
|
||||
* переключений. Сами суммы приходят с сервера по всей летописи и от выбранной закладки
|
||||
* НЕ зависят — иначе цифра «усохла» бы вместе с лентой.
|
||||
*/
|
||||
const channels = ref<AdWalletChannelTab[]>([]);
|
||||
const spentTotal = ref('0.00');
|
||||
const activeChannel = ref<string | null>(null);
|
||||
|
||||
/** «18400.00» (bcmath-строка) → «18 400 ₽» — тот же формат, что у AdWalletHeader/BillingView. */
|
||||
function formatMoney(value: string): string {
|
||||
return formatPlain(Number(value));
|
||||
/** Суммы на закладках каналов — всегда с копейками; общий форматтер портала. */
|
||||
function formatRowMoney(value: string): string {
|
||||
return formatExact(Number(value));
|
||||
}
|
||||
|
||||
async function loadWallet(): Promise<void> {
|
||||
@@ -63,78 +90,88 @@ async function loadWallet(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
historyLoading.value = true;
|
||||
async function loadReport(): Promise<void> {
|
||||
reportError.value = null;
|
||||
try {
|
||||
report.value = await fetchWalletReport(from.value, to.value, activeChannel.value);
|
||||
} catch (e) {
|
||||
report.value = null;
|
||||
reportError.value = extractErrorMessage(e, 'Не удалось загрузить отчёт по расходу.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(dobavlyaem = false): Promise<void> {
|
||||
historyLoading.value = !dobavlyaem;
|
||||
historyError.value = null;
|
||||
try {
|
||||
const otvet = await fetchWalletTransactions(activeChannel.value);
|
||||
history.value = otvet.items;
|
||||
channels.value = otvet.channels;
|
||||
spentTotal.value = otvet.spent_total_rub;
|
||||
const otvet = await fetchWalletTransactions({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
channel: activeChannel.value,
|
||||
source_type: vybranaKampaniya.value?.source_type ?? null,
|
||||
source_id: vybranaKampaniya.value?.source_id ?? null,
|
||||
before_id: dobavlyaem ? nextBeforeId.value : null,
|
||||
});
|
||||
history.value = dobavlyaem ? [...history.value, ...otvet.items] : otvet.items;
|
||||
hasMore.value = otvet.has_more;
|
||||
nextBeforeId.value = otvet.next_before_id;
|
||||
} catch (e) {
|
||||
// История не загрузилась — это не повод рушить экран: суммы уже на месте.
|
||||
history.value = [];
|
||||
historyError.value = extractErrorMessage(e, 'Не удалось загрузить историю операций.');
|
||||
if (!dobavlyaem) history.value = [];
|
||||
historyError.value = extractErrorMessage(e, 'Не удалось загрузить движения по кошельку.');
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Переключение закладки: лента перечитывается с отбором, суммы на закладках не трогаем. */
|
||||
function selectChannel(code: string | null): void {
|
||||
if (activeChannel.value === code) return;
|
||||
activeChannel.value = code;
|
||||
/** Пишет текущий период в адрес страницы — обновление не сбрасывает выбор. */
|
||||
function zapisatPeriodVAdres(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('from', from.value);
|
||||
url.searchParams.set('to', to.value);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
function smenitPeriod(p: { from: string; to: string }): void {
|
||||
from.value = p.from;
|
||||
to.value = p.to;
|
||||
zapisatPeriodVAdres();
|
||||
void loadReport();
|
||||
void loadHistory();
|
||||
}
|
||||
|
||||
/** «-3333.36» → «−3 333,36 ₽». Знак рисуем сами: у formatPlain его нет. */
|
||||
function formatSigned(value: string): string {
|
||||
const n = Number(value);
|
||||
const znak = n < 0 ? '−' : n > 0 ? '+' : '';
|
||||
|
||||
return znak + formatPlain(Math.abs(n));
|
||||
/** Выбор закладки канала — сбрасывает отбор по кампании (он был в другом канале). */
|
||||
function vybratKanal(code: string | null): void {
|
||||
if (activeChannel.value === code) return;
|
||||
activeChannel.value = code;
|
||||
vybranaKampaniya.value = null;
|
||||
void loadReport();
|
||||
void loadHistory();
|
||||
}
|
||||
|
||||
/** Дата движения по МОСКВЕ — время в кабинете всегда московское (решение владельца). */
|
||||
function formatMoment(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Цвет суммы. Заморозка и её снятие баланс не двигают — их не красим ни в плюс, ни в минус,
|
||||
* чтобы клиент не принял резерв за списание.
|
||||
*/
|
||||
function amountClass(t: AdWalletTransaction): string {
|
||||
if (!t.moves_balance) return 'text-medium-emphasis';
|
||||
|
||||
return Number(t.amount_rub) < 0 ? 'text-error' : 'text-success';
|
||||
/** Клик по строке таблицы «Куда ушли» — сама таблица уже снимает отбор повторным кликом. */
|
||||
function vybratKampaniyu(row: AdWalletCampaignRow | null): void {
|
||||
vybranaKampaniya.value = row;
|
||||
void loadHistory();
|
||||
}
|
||||
|
||||
function openTopup(): void {
|
||||
showTopupDialog.value = true;
|
||||
}
|
||||
|
||||
/** Диалог сообщил об успешном пополнении (картой ok:true, счёт оплачен позже вебхуком) — перечитать баланс. */
|
||||
/** Диалог сообщил об успешном пополнении — перечитать все три ручки. */
|
||||
function onToppedUp(): void {
|
||||
void loadWallet();
|
||||
void loadReport();
|
||||
void loadHistory();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadWallet();
|
||||
void loadReport();
|
||||
void loadHistory();
|
||||
});
|
||||
|
||||
defineExpose({ loadWallet, loadHistory, wallet });
|
||||
defineExpose({ loadWallet, loadReport, loadHistory, wallet });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -147,177 +184,88 @@ defineExpose({ loadWallet, loadHistory, wallet });
|
||||
Единый баланс для всех рекламных каналов Лидерры
|
||||
</p>
|
||||
|
||||
<v-card variant="flat" class="pa-4 mb-6" data-testid="ad-wallet-balance-card">
|
||||
<div v-if="loading" class="d-flex justify-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" size="28" />
|
||||
<div class="dve-kolonki">
|
||||
<div class="levaya">
|
||||
<div v-if="loading" class="d-flex justify-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" size="28" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="loadError" class="text-body-2 text-error mb-0" data-testid="ad-wallet-load-error">
|
||||
{{ loadError }}
|
||||
</p>
|
||||
|
||||
<AdWalletMoneyColumn v-else-if="wallet" :wallet="wallet" @topup="openTopup" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="loadError" class="text-body-2 text-error mb-0" data-testid="ad-wallet-load-error">
|
||||
{{ loadError }}
|
||||
</p>
|
||||
<div class="pravaya">
|
||||
<AdWalletPeriodPicker :from="from" :to="to" @change="smenitPeriod" />
|
||||
|
||||
<template v-else-if="wallet">
|
||||
<v-alert
|
||||
v-if="!wallet.solvent"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
class="mb-4"
|
||||
icon="mdi-alert-circle-outline"
|
||||
data-testid="ad-wallet-insolvent-alert"
|
||||
>
|
||||
Пополните рекламный кошелёк — реклама остановлена.
|
||||
</v-alert>
|
||||
<div v-if="report && report.channels.length > 0" class="zakladki mb-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="zakladka"
|
||||
:class="{ 'zakladka--active': activeChannel === null }"
|
||||
data-testid="ad-wallet-tab-all"
|
||||
@click="vybratKanal(null)"
|
||||
>
|
||||
<span class="zakladka__title">Всё</span>
|
||||
<span class="zakladka__sum num">{{ formatRowMoney(report.spent_total_rub) }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="c in report.channels"
|
||||
:key="c.code"
|
||||
type="button"
|
||||
class="zakladka"
|
||||
:class="{ 'zakladka--active': activeChannel === c.code }"
|
||||
:data-testid="`ad-wallet-tab-${c.code}`"
|
||||
@click="vybratKanal(c.code)"
|
||||
>
|
||||
<span class="zakladka__title">{{ c.title }}</span>
|
||||
<span class="zakladka__sum num">{{ formatRowMoney(c.spent_rub) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12" sm="4">
|
||||
<div class="wallet-stat">
|
||||
<span class="wallet-stat-label text-caption text-medium-emphasis">Кошелёк</span>
|
||||
<span class="wallet-stat-value num" data-testid="ad-wallet-balance">{{
|
||||
formatMoney(wallet.balance_rub)
|
||||
}}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<div class="wallet-stat">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<span class="wallet-stat-label text-caption text-medium-emphasis">Заморожено</span>
|
||||
<v-tooltip text="Зарезервировано под уже запущенные кампании" location="top">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-icon
|
||||
v-bind="tipProps"
|
||||
icon="mdi-information-outline"
|
||||
size="14"
|
||||
class="text-medium-emphasis"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<span class="wallet-stat-value num" data-testid="ad-wallet-frozen">{{
|
||||
formatMoney(wallet.frozen_rub)
|
||||
}}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<div class="wallet-stat">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<span class="wallet-stat-label text-caption text-medium-emphasis">Свободно</span>
|
||||
<v-tooltip text="Доступно для запуска новых кампаний" location="top">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-icon
|
||||
v-bind="tipProps"
|
||||
icon="mdi-information-outline"
|
||||
size="14"
|
||||
class="text-medium-emphasis"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<span class="wallet-stat-value num text-primary" data-testid="ad-wallet-free">{{
|
||||
formatMoney(wallet.free_rub)
|
||||
}}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card variant="flat" class="pa-4 mb-4" data-testid="ad-wallet-report-card">
|
||||
<p v-if="reportError" class="text-body-2 text-error mb-0" data-testid="ad-wallet-report-error">
|
||||
{{ reportError }}
|
||||
</p>
|
||||
<AdWalletSpendChart v-else-if="report" :days="report.by_day" :total-rub="report.spent_total_rub" />
|
||||
</v-card>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mt-4"
|
||||
data-testid="ad-wallet-topup-btn"
|
||||
@click="openTopup"
|
||||
>
|
||||
Пополнить
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card>
|
||||
<v-card v-if="report" variant="flat" class="pa-4 mb-4" data-testid="ad-wallet-campaigns-card">
|
||||
<AdWalletCampaignTable
|
||||
:rows="report.by_campaign"
|
||||
:selected="vybranaKampaniya"
|
||||
@select="vybratKampaniyu"
|
||||
/>
|
||||
</v-card>
|
||||
|
||||
<v-card variant="flat" class="pa-4" data-testid="ad-wallet-history-card">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon icon="mdi-history" size="18" class="mr-2 text-medium-emphasis" />
|
||||
<span class="text-subtitle-2">История операций</span>
|
||||
<v-card variant="flat" class="pa-4" data-testid="ad-wallet-ledger-card">
|
||||
<v-chip
|
||||
v-if="vybranaKampaniya"
|
||||
closable
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
data-testid="ad-wallet-filter-chip"
|
||||
@click:close="vybratKampaniyu(null)"
|
||||
>
|
||||
{{ vybranaKampaniya.title }}
|
||||
</v-chip>
|
||||
|
||||
<p v-if="historyError" class="text-body-2 text-error mb-0" data-testid="ad-wallet-history-error">
|
||||
{{ historyError }}
|
||||
</p>
|
||||
<AdWalletLedger
|
||||
v-else
|
||||
:items="history"
|
||||
:has-more="hasMore"
|
||||
:loading="historyLoading"
|
||||
@more="loadHistory(true)"
|
||||
/>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Закладки по каналам. Сумма стоит на самой закладке, чтобы «сколько на что
|
||||
уходит» читалось одним взглядом. Закладки появляются только у каналов, где
|
||||
были движения, — пустых вкладок на экране не держим.
|
||||
-->
|
||||
<div v-if="channels.length > 0" class="zakladki mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="zakladka"
|
||||
:class="{ 'zakladka--active': activeChannel === null }"
|
||||
data-testid="ad-wallet-tab-all"
|
||||
@click="selectChannel(null)"
|
||||
>
|
||||
<span class="zakladka__title">Всё</span>
|
||||
<span class="zakladka__sum num">{{ formatMoney(spentTotal) }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="c in channels"
|
||||
:key="c.code"
|
||||
type="button"
|
||||
class="zakladka"
|
||||
:class="{ 'zakladka--active': activeChannel === c.code }"
|
||||
:data-testid="`ad-wallet-tab-${c.code}`"
|
||||
@click="selectChannel(c.code)"
|
||||
>
|
||||
<span class="zakladka__title">{{ c.title }}</span>
|
||||
<span class="zakladka__sum num">{{ formatMoney(c.spent_rub) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="channels.length > 0" class="text-caption text-medium-emphasis mb-3">
|
||||
Суммы на закладках — потрачено за всё время. Заморозка деньгами не считается:
|
||||
её ещё можно вернуть.
|
||||
</p>
|
||||
|
||||
<div v-if="historyLoading" class="d-flex justify-center py-4">
|
||||
<v-progress-circular indeterminate color="primary" size="24" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="historyError"
|
||||
class="text-body-2 text-error mb-0"
|
||||
data-testid="ad-wallet-history-error"
|
||||
>
|
||||
{{ historyError }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="history.length === 0"
|
||||
class="text-body-2 text-medium-emphasis mb-0"
|
||||
data-testid="ad-wallet-history-empty"
|
||||
>
|
||||
Движений по кошельку пока не было.
|
||||
</p>
|
||||
|
||||
<v-list v-else density="comfortable" class="py-0" bg-color="transparent">
|
||||
<v-list-item
|
||||
v-for="t in history"
|
||||
:key="t.id"
|
||||
class="px-0 history-row"
|
||||
data-testid="ad-wallet-history-row"
|
||||
>
|
||||
<div class="d-flex align-start justify-space-between ga-4 flex-wrap">
|
||||
<div>
|
||||
<div class="text-body-2">{{ t.title }}</div>
|
||||
<div v-if="t.description" class="text-caption text-medium-emphasis">
|
||||
{{ t.description }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ formatMoment(t.created_at) }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="num" :class="amountClass(t)">{{ formatSigned(t.amount_rub) }}</div>
|
||||
<div v-if="!t.moves_balance" class="text-caption text-medium-emphasis">
|
||||
баланс не изменился
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<AdWalletTopupDialog v-model="showTopupDialog" @topped-up="onToppedUp" />
|
||||
</div>
|
||||
@@ -330,6 +278,30 @@ defineExpose({ loadWallet, loadHistory, wallet });
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dve-kolonki {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.levaya {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
/* На узком экране колонки встают друг под друга, липкость снимается: иначе колонка
|
||||
денег съест пол-экрана и до ленты придётся продираться. */
|
||||
@media (max-width: 959px) {
|
||||
.dve-kolonki {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.levaya {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* Закладки каналов: на узком экране переносятся, а не режутся и не уезжают за край. */
|
||||
.zakladki {
|
||||
display: flex;
|
||||
@@ -376,19 +348,4 @@ defineExpose({ loadWallet, loadHistory, wallet });
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wallet-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wallet-stat-value {
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.history-row + .history-row {
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -493,6 +493,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group
|
||||
Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show');
|
||||
// История движений кошелька — клиент должен уметь объяснить себе каждую заморозку.
|
||||
Route::get('/wallet/transactions', 'App\Http\Controllers\Api\AdvertisingWalletController@transactions');
|
||||
// Отчёт за период: по дням, по кампаниям, по каналам. Даты московские.
|
||||
Route::get('/wallet/report', 'App\Http\Controllers\Api\AdvertisingWalletController@report');
|
||||
|
||||
// Кампании Директа для клиентского портала (Часть A, Task 12) — CRUD-черновик +
|
||||
// счётчик аудитории + запуск + креатив. Фронт (Часть B2) — отдельная задача.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Постраничная лента движений кошелька (Задача 5): отбор по датам/каналу/кампании,
|
||||
* листание по ключу `before_id`, страница 50 строк.
|
||||
*
|
||||
* 🔴 Закладки по каналам и итог трат отсюда УЕХАЛИ в отчёт (`/wallet/report`) —
|
||||
* см. KoshelyokOtchyotZaPeriodTest.php. Здесь проверяется только сама лента.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
$this->svc = app(AdWalletService::class);
|
||||
$this->svc->topup($this->tenant->id, '100000.00', 'yandex', 'Пополнение по счёту №12');
|
||||
// Пополнение из beforeEach иначе пишется с created_at «сейчас» и в тестах, что фильтруют
|
||||
// ленту по конкретным суткам, может случайно попасть в проверяемое окно — тест не должен
|
||||
// зависеть от того, какое сегодня число на календаре.
|
||||
AdWalletTransaction::where('tenant_id', $this->tenant->id)->where('type', 'topup')
|
||||
->update(['created_at' => '2020-01-01 00:00:00']);
|
||||
});
|
||||
|
||||
it('отдаёт страницу в 50 строк и говорит, что есть ещё', function () {
|
||||
$k = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Долгая',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '1.00', 'k'.$i);
|
||||
}
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions')
|
||||
->assertOk()->assertJsonStructure(['items', 'has_more'])->json();
|
||||
|
||||
expect($body['items'])->toHaveCount(50)
|
||||
->and($body['has_more'])->toBeTrue()
|
||||
->and($body['next_before_id'])->toBe($body['items'][49]['id']);
|
||||
});
|
||||
|
||||
it('«показать ещё» не задваивает и не теряет строки', function () {
|
||||
$k = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Долгая',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '1.00', 'k'.$i);
|
||||
}
|
||||
|
||||
$pervaya = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
|
||||
$vtoraya = $this->getJson('/api/advertising/wallet/transactions?before_id='.$pervaya['next_before_id'])
|
||||
->assertOk()->json();
|
||||
|
||||
$ids = array_merge(
|
||||
array_column($pervaya['items'], 'id'),
|
||||
array_column($vtoraya['items'], 'id'),
|
||||
);
|
||||
|
||||
expect(count($ids))->toBe(count(array_unique($ids)))
|
||||
->and($vtoraya['has_more'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('отбирает ленту по кампании', function () {
|
||||
$odna = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Первая',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$drugaya = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Вторая',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $odna->id, '10.00', 'a');
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $drugaya->id, '20.00', 'b');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions'
|
||||
.'?source_type=campaign&source_id='.$odna->id.'&channel=yandex')->assertOk()->json();
|
||||
|
||||
expect($body['items'])->toHaveCount(1)
|
||||
->and($body['items'][0]['description'])->toContain('«Первая»');
|
||||
});
|
||||
|
||||
it('отбирает ленту по каналу', function () {
|
||||
$k = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Смешанная',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '5.00', 'y:1');
|
||||
$this->svc->charge($this->tenant->id, 'sms', 'campaign', $k->id, '7.00', 's:1');
|
||||
|
||||
$items = $this->getJson('/api/advertising/wallet/transactions?channel=sms')->assertOk()->json('items');
|
||||
|
||||
expect($items)->toHaveCount(1)
|
||||
->and($items[0]['channel'])->toBe('sms')
|
||||
->and($items[0]['amount_rub'])->toBe('-7.00');
|
||||
});
|
||||
|
||||
it('отбирает ленту по московским суткам', function () {
|
||||
$k = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Ночная',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '9.00', 'n');
|
||||
AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('type', 'charge')->orderByDesc('id')->limit(1)
|
||||
->update(['created_at' => '2026-08-05 21:00:24']);
|
||||
|
||||
// 21:00 UTC 5-го = 00:00 МСК 6-го: в 5 августа не попадает, в 6-е попадает.
|
||||
expect($this->getJson('/api/advertising/wallet/transactions?from=2026-08-05&to=2026-08-05')
|
||||
->assertOk()->json('items'))->toHaveCount(0);
|
||||
expect($this->getJson('/api/advertising/wallet/transactions?from=2026-08-06&to=2026-08-06')
|
||||
->assertOk()->json('items'))->toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\Advertising\OtchyotKoshelka;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
$this->svc = app(AdWalletService::class);
|
||||
$this->svc->topup($this->tenant->id, '100000.00', 'yandex', 'Пополнение по счёту №12');
|
||||
});
|
||||
|
||||
function kampaniya(int $tenantId, string $name): AdCampaign
|
||||
{
|
||||
return AdCampaign::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $name,
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Списание задним числом: сервис пишет «сейчас», а нам нужен свой момент. */
|
||||
function spisanieV(int $tenantId, string $kanal, int $kampaniya, string $summa, string $utc): void
|
||||
{
|
||||
app(AdWalletService::class)->charge($tenantId, $kanal, 'campaign', $kampaniya, $summa, uniqid('k', true));
|
||||
AdWalletTransaction::where('tenant_id', $tenantId)
|
||||
->where('type', 'charge')->orderByDesc('id')->limit(1)
|
||||
->update(['created_at' => $utc]);
|
||||
}
|
||||
|
||||
it('🔴 списание в 21:00 UTC ложится на СЛЕДУЮЩИЙ день по Москве', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Ночная');
|
||||
// 21:00 UTC 5 августа = 00:00 МСК 6 августа. Живой пример с боевого, запись #69.
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '9.00', '2026-08-05 21:00:24');
|
||||
|
||||
$otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-06');
|
||||
|
||||
$poDnyam = collect($otchyot['by_day'])->keyBy('day');
|
||||
expect($poDnyam['2026-08-06']['spent_rub'])->toBe('9.00')
|
||||
->and($poDnyam['2026-08-05']['spent_rub'])->toBe('0.00');
|
||||
});
|
||||
|
||||
it('период берётся по московским суткам с обоих концов', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Краевая');
|
||||
// 20:59:59 UTC 4-го = 23:59:59 МСК 4-го — в период 5–6 августа НЕ входит.
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '1.00', '2026-08-04 20:59:59');
|
||||
// 20:59:59 UTC 6-го = 23:59:59 МСК 6-го — входит последней секундой.
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '2.00', '2026-08-06 20:59:59');
|
||||
// 21:00:00 UTC 6-го = 00:00 МСК 7-го — уже НЕ входит.
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '4.00', '2026-08-06 21:00:00');
|
||||
|
||||
$otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-06');
|
||||
|
||||
expect($otchyot['spent_total_rub'])->toBe('2.00');
|
||||
});
|
||||
|
||||
it('пустые дни периода остаются в ответе нулями, а не выпадают', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Одиночная');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '5.00', '2026-08-03 09:00:00');
|
||||
|
||||
$otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-01', '2026-08-05');
|
||||
|
||||
expect($otchyot['by_day'])->toHaveCount(5)
|
||||
->and(collect($otchyot['by_day'])->pluck('day')->all())
|
||||
->toBe(['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']);
|
||||
});
|
||||
|
||||
it('расходом считается только списание — заморозка и пополнение мимо', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Смешанная');
|
||||
$this->svc->freeze($this->tenant->id, 'yandex', 'campaign', $k->id, '800.00');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '12.00', '2026-08-05 09:00:00');
|
||||
$this->svc->topup($this->tenant->id, '500.00', 'yandex', 'Ещё пополнение');
|
||||
|
||||
$otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-05');
|
||||
|
||||
expect($otchyot['spent_total_rub'])->toBe('12.00');
|
||||
});
|
||||
|
||||
it('разрез по кампаниям: дорогие сверху, с подписью и долей', function () {
|
||||
$dorogaya = kampaniya($this->tenant->id, 'Я5 приёмка');
|
||||
$deshevaya = kampaniya($this->tenant->id, 'Я1 приёмка');
|
||||
spisanieV($this->tenant->id, 'yandex', $dorogaya->id, '126.00', '2026-08-05 09:00:00');
|
||||
spisanieV($this->tenant->id, 'yandex', $deshevaya->id, '24.00', '2026-08-05 10:00:00');
|
||||
|
||||
$otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-05');
|
||||
|
||||
expect($otchyot['by_campaign'][0]['title'])->toContain('«Я5 приёмка»')
|
||||
->and($otchyot['by_campaign'][0]['spent_rub'])->toBe('126.00')
|
||||
->and($otchyot['by_campaign'][0]['share_percent'])->toBe(84.0)
|
||||
->and($otchyot['by_campaign'][1]['spent_rub'])->toBe('24.00');
|
||||
});
|
||||
|
||||
it('закладки каналов не схлопываются при отборе по каналу', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Яндексовая');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '100.00', '2026-08-05 09:00:00');
|
||||
spisanieV($this->tenant->id, 'sms', 1, '20.00', '2026-08-05 10:00:00');
|
||||
|
||||
$otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-05', 'yandex');
|
||||
|
||||
// Отбор сузил дни и кампании, но закладки обязаны показать ОБА канала — иначе
|
||||
// переключиться обратно станет некуда.
|
||||
expect(collect($otchyot['channels'])->pluck('code')->all())->toBe(['yandex', 'sms'])
|
||||
->and($otchyot['spent_total_rub'])->toBe('100.00')
|
||||
->and($otchyot['by_campaign'])->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('ручка отчёта отдаёт период, дни, кампании и каналы', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Я5 приёмка');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '126.00', '2026-08-05 09:00:00');
|
||||
spisanieV($this->tenant->id, 'sms', 1, '27.00', '2026-08-05 10:00:00');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')
|
||||
->assertOk()->json();
|
||||
|
||||
expect($body['from'])->toBe('2026-08-05')
|
||||
->and($body['spent_total_rub'])->toBe('153.00')
|
||||
->and($body['by_campaign'][0]['title'])->toContain('«Я5 приёмка»')
|
||||
->and($body['by_campaign'][0]['spent_rub'])->toBe('126.00')
|
||||
->and($body['by_campaign'][0]['share_percent'])->toBe(82.4)
|
||||
->and(collect($body['channels'])->pluck('code')->all())->toBe(['yandex', 'sms']);
|
||||
});
|
||||
|
||||
it('без дат ручка берёт последние 30 дней и не падает', function () {
|
||||
$body = $this->getJson('/api/advertising/wallet/report')->assertOk()->json();
|
||||
|
||||
expect($body['by_day'])->toHaveCount(30)->and($body['spent_total_rub'])->toBe('0.00');
|
||||
});
|
||||
|
||||
it('кривые даты не роняют ручку, а получают понятный отказ', function () {
|
||||
$this->getJson('/api/advertising/wallet/report?from=позавчера&to=2026-08-05')
|
||||
->assertStatus(422);
|
||||
// Конец раньше начала — тоже отказ, а не молча вывернутый период.
|
||||
$this->getJson('/api/advertising/wallet/report?from=2026-08-10&to=2026-08-01')
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('чужой тенант своего отчёта не видит', function () {
|
||||
$chuzhoy = Tenant::factory()->create();
|
||||
$chuzhoyUser = User::factory()->create(['tenant_id' => $chuzhoy->id]);
|
||||
$k = kampaniya($this->tenant->id, 'Наша');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '50.00', '2026-08-05 09:00:00');
|
||||
|
||||
$body = $this->actingAs($chuzhoyUser)
|
||||
->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')
|
||||
->assertOk()->json();
|
||||
|
||||
expect($body['spent_total_rub'])->toBe('0.00')->and($body['by_campaign'])->toBe([]);
|
||||
});
|
||||
|
||||
it('число запросов не растёт вместе с числом кампаний', function () {
|
||||
$kampanii = [];
|
||||
for ($i = 0; $i < 12; $i++) {
|
||||
$kampanii[] = kampaniya($this->tenant->id, 'Кампания '.$i);
|
||||
}
|
||||
foreach ($kampanii as $k) {
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '3.00', '2026-08-05 09:00:00');
|
||||
}
|
||||
|
||||
DB::enableQueryLog();
|
||||
// 🪤 Одного `assertOk()` мало: на любой неизвестный путь проект отвечает страницей
|
||||
// Vue с кодом 200 (`Route::fallback` в routes/web.php). Сторож, проверяющий только
|
||||
// код ответа, спокойно зеленел бы и без самой ручки. Требуем поле из отчёта.
|
||||
$this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')
|
||||
->assertOk()->assertJsonStructure(['spent_total_rub', 'by_day', 'by_campaign']);
|
||||
$skolko = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
// Три сводных запроса + один на имена + служебные (сессия, пользователь, контекст RLS).
|
||||
// Порог с запасом; если число растёт линейно с кампаниями — вопрос попал внутрь перебора.
|
||||
expect($skolko)->toBeLessThan(15);
|
||||
});
|
||||
|
||||
/**
|
||||
* Закладки по каналам (`channels` + `spent_total_rub`) — правило владельца 06.08.2026:
|
||||
* «раздели списание на закладки! чтобы было чётко видно сколько человек тратит на какую
|
||||
* рекламу». Переехало сюда из ленты (Задача 5, 06.08.2026): закладки согласованы с
|
||||
* выбранным периодом, а не со случайной сотней последних строк.
|
||||
*/
|
||||
function zakladkaKanala(array $body, string $code): array
|
||||
{
|
||||
foreach ($body['channels'] ?? [] as $c) {
|
||||
if ($c['code'] === $code) {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
it('закладки канала в отчёте показывают сумму трат по каждому, сложенную по всем строкам', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Я5');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '126.00', '2026-08-05 09:00:00');
|
||||
spisanieV($this->tenant->id, 'sms', 1, '27.00', '2026-08-05 10:00:00');
|
||||
spisanieV($this->tenant->id, 'sms', 2, '3.00', '2026-08-05 10:05:00');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
|
||||
|
||||
expect(zakladkaKanala($body, 'yandex')['title'] ?? '')->toBe('Яндекс Аудитория')
|
||||
->and(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('126.00')
|
||||
->and(zakladkaKanala($body, 'sms')['title'] ?? '')->toBe('Рассылка СМС')
|
||||
// 27 + 3 — траты по каналу складываются, а не показывают последнюю.
|
||||
->and(zakladkaKanala($body, 'sms')['spent_rub'] ?? '')->toBe('30.00');
|
||||
|
||||
expect($body['spent_total_rub'])->toBe('156.00');
|
||||
});
|
||||
|
||||
it('закладка канала в отчёте не считает заморозку и пополнение расходом', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Резерв');
|
||||
$this->svc->freeze($this->tenant->id, 'yandex', 'campaign', $k->id, '850.00');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '10.00', '2026-08-05 09:00:00');
|
||||
$this->svc->release($this->tenant->id, 'yandex', 'campaign', $k->id);
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
|
||||
|
||||
expect(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('10.00')
|
||||
->and($body['spent_total_rub'])->toBe('10.00');
|
||||
});
|
||||
|
||||
it('сумма трат по каналу в отчёте считается по ВСЕЙ выборке периода, а не по ограниченному числу строк', function () {
|
||||
// Один давний расход по СМС, а сверху — 120 отдельных списаний по Яндексу в тот же
|
||||
// день. Если кто-то однажды добавит LIMIT в агрегатный запрос отчёта — сумма канала
|
||||
// тихо «усохнет» ровно тогда, когда строк наберётся больше предела.
|
||||
spisanieV($this->tenant->id, 'sms', 1, '40.00', '2026-08-05 08:00:00');
|
||||
|
||||
$stroki = [];
|
||||
for ($i = 0; $i < 120; $i++) {
|
||||
$stroki[] = [
|
||||
'tenant_id' => $this->tenant->id, 'type' => AdWalletTransaction::TYPE_CHARGE,
|
||||
'amount_rub' => '-1.00', 'balance_rub_after' => '0.00', 'channel' => 'yandex',
|
||||
'related_type' => 'campaign', 'related_id' => 9, 'external_key' => 'y:massa:'.$i,
|
||||
'description' => 'Списание за рекламу (факт)',
|
||||
'created_at' => '2026-08-05 09:00:00',
|
||||
];
|
||||
}
|
||||
AdWalletTransaction::insert($stroki);
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
|
||||
|
||||
expect(zakladkaKanala($body, 'sms')['spent_rub'] ?? '')->toBe('40.00')
|
||||
->and(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('120.00')
|
||||
->and($body['spent_total_rub'])->toBe('160.00');
|
||||
});
|
||||
|
||||
it('закладки канала не выдумываются: канала без движений в списке нет', function () {
|
||||
$k = kampaniya($this->tenant->id, 'Одна');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '5.00', '2026-08-05 09:00:00');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
|
||||
$kody = array_column($body['channels'] ?? [], 'code');
|
||||
|
||||
expect($kody)->toContain('yandex')
|
||||
->and($kody)->not->toContain('telegram')
|
||||
->and($kody)->not->toContain('vk');
|
||||
});
|
||||
|
||||
it('закладки канала в отчёте: чужие траты внутрь не попадают', function () {
|
||||
$chuzhoy = Tenant::factory()->create();
|
||||
AdWallet::create(['tenant_id' => $chuzhoy->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
app(AdWalletService::class)->topup($chuzhoy->id, '5000.00', 'yandex', 'чужое');
|
||||
app(AdWalletService::class)->charge($chuzhoy->id, 'yandex', 'campaign', 1, '999.00', 'chuzhoy:1');
|
||||
AdWalletTransaction::where('tenant_id', $chuzhoy->id)->where('type', 'charge')
|
||||
->orderByDesc('id')->limit(1)->update(['created_at' => '2026-08-05 09:00:00']);
|
||||
|
||||
$k = kampaniya($this->tenant->id, 'Наша');
|
||||
spisanieV($this->tenant->id, 'yandex', $k->id, '5.00', '2026-08-05 09:00:00');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
|
||||
|
||||
expect(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('5.00')
|
||||
->and($body['spent_total_rub'])->toBe('5.00');
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\ClientSmsCampaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\Advertising\OpisanieDvizheniyaKoshelka;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('подписывает владельцев по паре «канал + вид + номер»', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$yandex = AdCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Я3 приёмка — приняли часть, вся смета заперта',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$sms = ClientSmsCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'title' => 'Августовская рассылка',
|
||||
'body' => 'текст',
|
||||
'sender_name' => 'LIDERRA',
|
||||
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$podpisi = app(OpisanieDvizheniyaKoshelka::class)->dlyaVladeltsev([
|
||||
['channel' => 'yandex', 'source_type' => 'campaign', 'source_id' => $yandex->id],
|
||||
['channel' => 'sms', 'source_type' => 'campaign', 'source_id' => $sms->id],
|
||||
]);
|
||||
|
||||
// Длинное название обрезается многоточием на 40 знаках — как в ленте.
|
||||
expect($podpisi['yandex:campaign:'.$yandex->id])
|
||||
->toBe('кампания №'.$yandex->id.' «Я3 приёмка — приняли часть, вся смета за…»')
|
||||
->and($podpisi['sms:campaign:'.$sms->id])
|
||||
->toBe('кампания №'.$sms->id.' «Августовская рассылка»');
|
||||
});
|
||||
|
||||
it('не путает номера Яндекса и СМС между собой', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$yandex = AdCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Яндекс',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$sms = ClientSmsCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'title' => 'СМС',
|
||||
'body' => 'текст',
|
||||
'sender_name' => 'LIDERRA',
|
||||
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$podpisi = app(OpisanieDvizheniyaKoshelka::class)->dlyaVladeltsev([
|
||||
['channel' => 'yandex', 'source_type' => 'campaign', 'source_id' => $yandex->id],
|
||||
['channel' => 'sms', 'source_type' => 'campaign', 'source_id' => $sms->id],
|
||||
]);
|
||||
|
||||
expect($podpisi['yandex:campaign:'.$yandex->id])->toContain('«Яндекс»')
|
||||
->and($podpisi['sms:campaign:'.$sms->id])->toContain('«СМС»');
|
||||
});
|
||||
|
||||
it('владельца без записи в базе подписывает одним номером, без выдумок', function () {
|
||||
$podpisi = app(OpisanieDvizheniyaKoshelka::class)->dlyaVladeltsev([
|
||||
['channel' => 'yandex', 'source_type' => 'campaign', 'source_id' => 999999],
|
||||
]);
|
||||
|
||||
expect($podpisi['yandex:campaign:999999'])->toBe('кампания №999999');
|
||||
});
|
||||
|
||||
it('отдаёт расшифровку заморозки: под что заперто и когда вернётся', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$this->actingAs($user);
|
||||
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
$svc = app(AdWalletService::class);
|
||||
$svc->topup($tenant->id, '10000.00', 'yandex', 'Пополнение по счёту №12');
|
||||
|
||||
$bolshaya = AdCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Я3 приёмка',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
'shows_until' => '2026-08-06',
|
||||
]);
|
||||
$malaya = AdCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Я1 приёмка',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
'shows_until' => null,
|
||||
]);
|
||||
|
||||
$svc->freeze($tenant->id, 'yandex', 'campaign', $bolshaya->id, '833.50');
|
||||
$svc->freeze($tenant->id, 'yandex', 'campaign', $malaya->id, '849.00');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet')->assertOk()->json();
|
||||
|
||||
// Сумма расшифровки обязана сойтись с frozen_rub кошелька — иначе это не расшифровка.
|
||||
$summa = array_reduce($body['frozen_breakdown'], fn ($s, $r) => bcadd($s, $r['amount_rub'], 2), '0.00');
|
||||
expect($summa)->toBe($body['frozen_rub'])->toBe('1682.50');
|
||||
|
||||
// Крупная заморозка стоит первой.
|
||||
expect($body['frozen_breakdown'][0]['source_id'])->toBe($malaya->id)
|
||||
->and($body['frozen_breakdown'][0]['amount_rub'])->toBe('849.00')
|
||||
->and($body['frozen_breakdown'][0]['channel_title'])->toBe('Яндекс Аудитория')
|
||||
->and($body['frozen_breakdown'][0]['title'])->toContain('«Я1 приёмка»')
|
||||
// Срока нет — пишем null, а не выдуманную дату.
|
||||
->and($body['frozen_breakdown'][0]['returns_on'])->toBeNull()
|
||||
->and($body['frozen_breakdown'][1]['returns_on'])->toBe('2026-08-06');
|
||||
});
|
||||
|
||||
it('снятая заморозка в расшифровку не попадает', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$this->actingAs($user);
|
||||
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
$svc = app(AdWalletService::class);
|
||||
$svc->topup($tenant->id, '10000.00', 'yandex', 'Пополнение');
|
||||
$c = AdCampaign::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Ушедшая',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'estimated_impressions' => 1000,
|
||||
'paid_impressions' => 1000,
|
||||
'charged_client_rub' => '0.00',
|
||||
'shows_until' => null,
|
||||
]);
|
||||
$svc->freeze($tenant->id, 'yandex', 'campaign', $c->id, '700.00');
|
||||
$svc->release($tenant->id, 'yandex', 'campaign', $c->id);
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet')->assertOk()->json();
|
||||
|
||||
expect($body['frozen_breakdown'])->toBe([])->and($body['frozen_rub'])->toBe('0.00');
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Закладки по каналам в истории кошелька: «сколько человек тратит на какую рекламу».
|
||||
*
|
||||
* 🔴 Владелец, 06.08.2026: «раздели списание на закладки! чтобы было чётко видно сколько
|
||||
* человек тратит на какую рекламу». Кошелёк общий на все каналы, и в одной ленте
|
||||
* вперемешку идут Яндекс, СМС и Телеграм — понять, куда уходят деньги, было нельзя.
|
||||
*
|
||||
* 🔴 Главное правило сторожа: сумма трат по каналу считается по ВСЕЙ летописи, а не по
|
||||
* сотне последних строк, которые отдаются в ленту. Иначе у давнего канала цифра
|
||||
* потихоньку «усохнет» ровно тогда, когда лента переполнится, — и никто не заметит.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
$this->svc = app(AdWalletService::class);
|
||||
$this->svc->topup($this->tenant->id, '100000.00', 'yandex', 'Пополнение по счёту №12');
|
||||
});
|
||||
|
||||
/** Закладка канала из ответа ручки. */
|
||||
function zakladka(array $body, string $code): array
|
||||
{
|
||||
foreach ($body['channels'] ?? [] as $c) {
|
||||
if ($c['code'] === $code) {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
it('отдаёт закладки по каналам с суммой трат по каждому', function () {
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '126.00', 'y:1');
|
||||
$this->svc->charge($this->tenant->id, 'sms', 'campaign', 1, '27.00', 's:1');
|
||||
$this->svc->charge($this->tenant->id, 'sms', 'campaign', 2, '3.00', 's:2');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
|
||||
|
||||
expect(zakladka($body, 'yandex')['title'] ?? '')->toBe('Яндекс Аудитория')
|
||||
->and(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('126.00')
|
||||
->and(zakladka($body, 'sms')['title'] ?? '')->toBe('Рассылка СМС')
|
||||
// 27 + 3 — траты по каналу складываются, а не показывают последнюю.
|
||||
->and(zakladka($body, 'sms')['spent_rub'] ?? '')->toBe('30.00');
|
||||
|
||||
// Итог по всем каналам — та же арифметика, что и в сумме закладок.
|
||||
expect($body['spent_total_rub'] ?? '')->toBe('156.00');
|
||||
});
|
||||
|
||||
it('заморозка и пополнение в траты не попадают — это не расход', function () {
|
||||
$this->svc->freeze($this->tenant->id, 'yandex', 'campaign', 5, '850.00');
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', 5, '10.00', 'y:5');
|
||||
$this->svc->release($this->tenant->id, 'yandex', 'campaign', 5);
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
|
||||
|
||||
expect(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('10.00')
|
||||
->and($body['spent_total_rub'] ?? '')->toBe('10.00');
|
||||
});
|
||||
|
||||
it('сумма трат считается по ВСЕЙ летописи, а не по сотне последних строк', function () {
|
||||
// Один давний расход по СМС, а сверху — сто с лишним свежих движений по Яндексу.
|
||||
$this->svc->charge($this->tenant->id, 'sms', 'campaign', 1, '40.00', 's:staryy');
|
||||
|
||||
$stroki = [];
|
||||
for ($i = 0; $i < 120; $i++) {
|
||||
$stroki[] = [
|
||||
'tenant_id' => $this->tenant->id, 'type' => AdWalletTransaction::TYPE_CHARGE,
|
||||
'amount_rub' => '-1.00', 'balance_rub_after' => '0.00', 'channel' => 'yandex',
|
||||
'related_type' => 'campaign', 'related_id' => 9, 'external_key' => 'y:massa:'.$i,
|
||||
'description' => 'Списание за рекламу (факт)',
|
||||
// У летописи нет `updated_at` — запись денежного движения не правится.
|
||||
'created_at' => now()->addSeconds($i + 1),
|
||||
];
|
||||
}
|
||||
AdWalletTransaction::insert($stroki);
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
|
||||
|
||||
// В ленту влезло только 100 свежих — и все они яндексовые.
|
||||
expect($body['items'])->toHaveCount(100);
|
||||
// Но давние 40 ₽ по СМС из суммы не пропали.
|
||||
expect(zakladka($body, 'sms')['spent_rub'] ?? '')->toBe('40.00')
|
||||
->and(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('120.00')
|
||||
->and($body['spent_total_rub'] ?? '')->toBe('160.00');
|
||||
});
|
||||
|
||||
it('закладка показывает только свои движения', function () {
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '5.00', 'y:1');
|
||||
$this->svc->charge($this->tenant->id, 'sms', 'campaign', 1, '7.00', 's:1');
|
||||
|
||||
$items = $this->getJson('/api/advertising/wallet/transactions?channel=sms')->assertOk()->json('items');
|
||||
|
||||
expect($items)->toHaveCount(1)
|
||||
->and($items[0]['channel'])->toBe('sms')
|
||||
->and($items[0]['amount_rub'])->toBe('-7.00');
|
||||
});
|
||||
|
||||
it('закладки не выдумываются: канала без движений в списке нет', function () {
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '5.00', 'y:1');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
|
||||
$kody = array_column($body['channels'] ?? [], 'code');
|
||||
|
||||
expect($kody)->toContain('yandex')
|
||||
->and($kody)->not->toContain('telegram')
|
||||
->and($kody)->not->toContain('vk');
|
||||
});
|
||||
|
||||
it('чужие траты в закладки не попадают', function () {
|
||||
$chuzhoy = Tenant::factory()->create();
|
||||
AdWallet::create(['tenant_id' => $chuzhoy->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
|
||||
$this->svc->topup($chuzhoy->id, '5000.00', 'yandex', 'чужое');
|
||||
$this->svc->charge($chuzhoy->id, 'yandex', 'campaign', 1, '999.00', 'chuzhoy:1');
|
||||
|
||||
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '5.00', 'y:1');
|
||||
|
||||
$body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
|
||||
|
||||
expect(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('5.00')
|
||||
->and($body['spent_total_rub'] ?? '')->toBe('5.00');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdWalletMoneyColumn from '../../resources/js/components/advertising/AdWalletMoneyColumn.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const koshelek = {
|
||||
solvent: true,
|
||||
balance_rub: '9914.00',
|
||||
frozen_rub: '2507.00',
|
||||
free_rub: '7407.00',
|
||||
frozen_breakdown: [
|
||||
{
|
||||
channel: 'yandex', channel_title: 'Яндекс Аудитория',
|
||||
source_type: 'campaign', source_id: 19,
|
||||
title: 'кампания №19 «Я3 приёмка»', amount_rub: '833.50', returns_on: '2026-08-07',
|
||||
},
|
||||
{
|
||||
channel: 'yandex', channel_title: 'Яндекс Аудитория',
|
||||
source_type: 'campaign', source_id: 16,
|
||||
title: 'кампания №16 «Я1 приёмка»', amount_rub: '849.00', returns_on: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('AdWalletMoneyColumn — левая колонка денег', () => {
|
||||
it('показывает свободно крупно, а всего и заперто — рядом', () => {
|
||||
const w = mount(AdWalletMoneyColumn, {
|
||||
props: { wallet: koshelek }, global: { plugins: [vuetify] },
|
||||
});
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/7\s407/);
|
||||
expect(w.find('[data-testid="ad-wallet-balance"]').text()).toMatch(/9\s914/);
|
||||
expect(w.find('[data-testid="ad-wallet-frozen"]').text()).toMatch(/2\s507/);
|
||||
});
|
||||
|
||||
it('расшифровывает заморозку строками с датой возврата', () => {
|
||||
const w = mount(AdWalletMoneyColumn, {
|
||||
props: { wallet: koshelek }, global: { plugins: [vuetify] },
|
||||
});
|
||||
|
||||
const rows = w.findAll('[data-testid="ad-wallet-frozen-row"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].text()).toContain('«Я3 приёмка»');
|
||||
expect(rows[0].text()).toMatch(/833,50/);
|
||||
expect(rows[0].text()).toContain('вернётся 7 августа');
|
||||
// Срока нет — так и пишем, дату не выдумываем.
|
||||
expect(rows[1].text()).toContain('срок не задан');
|
||||
});
|
||||
|
||||
it('без заморозок пишет понятную строку, а не пустое место', () => {
|
||||
const w = mount(AdWalletMoneyColumn, {
|
||||
props: { wallet: { ...koshelek, frozen_rub: '0.00', frozen_breakdown: [] } },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-frozen-empty"]').exists()).toBe(true);
|
||||
expect(w.findAll('[data-testid="ad-wallet-frozen-row"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('при solvent=false показывает заметный алерт', () => {
|
||||
const w = mount(AdWalletMoneyColumn, {
|
||||
props: { wallet: { ...koshelek, solvent: false } }, global: { plugins: [vuetify] },
|
||||
});
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-insolvent-alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('кнопка «Пополнить» шлёт событие наверх', async () => {
|
||||
const w = mount(AdWalletMoneyColumn, {
|
||||
props: { wallet: koshelek }, global: { plugins: [vuetify] },
|
||||
});
|
||||
|
||||
await w.find('[data-testid="ad-wallet-topup-btn"]').trigger('click');
|
||||
|
||||
expect(w.emitted('topup')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdWalletLedger from '../../resources/js/components/advertising/AdWalletLedger.vue';
|
||||
import type { AdWalletTransaction } from '../../resources/js/api/advertising';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
// Тип задан явно: без него `type` выводится как `string` и не подходит под союз видов.
|
||||
const dvizheniya: AdWalletTransaction[] = [
|
||||
{
|
||||
id: 75, type: 'charge', title: 'Списание за рекламу', amount_rub: '-4.50',
|
||||
balance_rub_after: '9914.00', moves_balance: true, channel: 'yandex',
|
||||
description: 'Яндекс Аудитория · кампания №21 «Я4 приёмка»',
|
||||
created_at: '2026-08-06T05:00:23+00:00',
|
||||
},
|
||||
{
|
||||
id: 74, type: 'charge', title: 'Списание за рекламу', amount_rub: '-0.50',
|
||||
balance_rub_after: '9918.50', moves_balance: true, channel: 'yandex',
|
||||
description: 'Яндекс Аудитория · кампания №16 «Я1 приёмка»',
|
||||
created_at: '2026-08-06T04:00:24+00:00',
|
||||
},
|
||||
{
|
||||
id: 70, type: 'release', title: 'Резерв снят', amount_rub: '724.00',
|
||||
balance_rub_after: '9918.50', moves_balance: false, channel: 'yandex',
|
||||
description: 'Яндекс Аудитория · кампания №13 «Я5 приёмка»',
|
||||
created_at: '2026-08-05T21:00:16+00:00',
|
||||
},
|
||||
];
|
||||
|
||||
function postavit(items = dvizheniya, hasMore = false, loading = false) {
|
||||
return mount(AdWalletLedger, {
|
||||
props: { items, hasMore, loading }, global: { plugins: [vuetify] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('AdWalletLedger — лента по дням', () => {
|
||||
it('🔴 21:00 по Гринвичу попадает в СЛЕДУЮЩИЙ московский день', () => {
|
||||
const w = postavit();
|
||||
|
||||
// Все три движения — один московский день, 6 августа.
|
||||
const dni = w.findAll('[data-testid="ad-wallet-ledger-day"]');
|
||||
expect(dni).toHaveLength(1);
|
||||
expect(dni[0].text()).toContain('6 августа');
|
||||
});
|
||||
|
||||
it('у дня стоит итог — сумма того, что реально ушло', () => {
|
||||
const w = postavit();
|
||||
|
||||
// 4,50 + 0,50 = 5,00. Снятие резерва баланс не двигает и в итог дня не идёт.
|
||||
expect(w.find('[data-testid="ad-wallet-ledger-day-total"]').text()).toMatch(/5,00/);
|
||||
});
|
||||
|
||||
it('движения показаны с подписью, каналом и временем', () => {
|
||||
const w = postavit();
|
||||
|
||||
const rows = w.findAll('[data-testid="ad-wallet-ledger-row"]');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0].text()).toContain('Списание за рекламу');
|
||||
expect(rows[0].text()).toContain('«Я4 приёмка»');
|
||||
// Заморозка и снятие резерва помечены отдельно.
|
||||
expect(rows[2].text()).toContain('баланс не изменился');
|
||||
});
|
||||
|
||||
it('«Показать ещё» видна только когда есть ещё, и шлёт событие', async () => {
|
||||
const w = postavit(dvizheniya, true);
|
||||
|
||||
await w.find('[data-testid="ad-wallet-ledger-more"]').trigger('click');
|
||||
|
||||
expect(w.emitted('more')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('пусто — понятная строка, а не пустое место', () => {
|
||||
const w = postavit([]);
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-ledger-empty"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="ad-wallet-ledger-more"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('пока грузится — крутилка, а не «движений не было»', () => {
|
||||
const w = postavit([], false, true);
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-ledger-empty"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdWalletSpendChart from '../../resources/js/components/advertising/AdWalletSpendChart.vue';
|
||||
import type { AdWalletDay } from '../../resources/js/api/advertising';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
// Тип задан явно: без него вывод сужает `by_channel` до формы первой строки, и день
|
||||
// без движений (пустой объект) перестаёт быть допустимым.
|
||||
const dni: AdWalletDay[] = [
|
||||
{ day: '2026-08-04', spent_rub: '3.24', by_channel: { yandex: '3.24' } },
|
||||
{ day: '2026-08-05', spent_rub: '124.00', by_channel: { yandex: '124.00' } },
|
||||
{ day: '2026-08-06', spent_rub: '0.00', by_channel: {} },
|
||||
];
|
||||
|
||||
function postavit(days = dni, totalRub = '127.24') {
|
||||
return mount(AdWalletSpendChart, { props: { days, totalRub }, global: { plugins: [vuetify] } });
|
||||
}
|
||||
|
||||
describe('AdWalletSpendChart — столбики по дням', () => {
|
||||
it('рисует столбик на каждый день периода, включая нулевые', () => {
|
||||
const w = postavit();
|
||||
|
||||
expect(w.findAll('[data-testid="ad-wallet-chart-bar"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('высота столбика считается от самого большого дня', () => {
|
||||
const w = postavit();
|
||||
|
||||
const bars = w.findAll('[data-testid="ad-wallet-chart-bar"]');
|
||||
// 124,00 — самый большой день, он и есть 100%.
|
||||
expect(bars[1].attributes('style')).toContain('height: 100%');
|
||||
// Нулевой день не исчезает: остаётся видимая ниточка.
|
||||
expect(bars[2].attributes('style')).toContain('height: 2px');
|
||||
});
|
||||
|
||||
it('в подсказке — день и сумма человеческими словами', () => {
|
||||
const w = postavit();
|
||||
|
||||
const bars = w.findAll('[data-testid="ad-wallet-chart-bar"]');
|
||||
expect(bars[1].attributes('title')).toContain('5 августа');
|
||||
expect(bars[1].attributes('title')).toMatch(/124,00/);
|
||||
});
|
||||
|
||||
it('итог за период показан крупно', () => {
|
||||
const w = postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-chart-total"]').text()).toMatch(/127,24/);
|
||||
});
|
||||
|
||||
it('за период ничего не потрачено — пишет об этом, а не рисует пустоту', () => {
|
||||
const w = postavit([{ day: '2026-08-06', spent_rub: '0.00', by_channel: {} }], '0.00');
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-chart-empty"]').exists()).toBe(true);
|
||||
expect(w.findAll('[data-testid="ad-wallet-chart-bar"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('день красится цветом канала, который в этот день потратил больше всех', () => {
|
||||
const w = postavit([
|
||||
{ day: '2026-08-05', spent_rub: '30.00', by_channel: { yandex: '10.00', sms: '20.00' } },
|
||||
{ day: '2026-08-06', spent_rub: '30.00', by_channel: { yandex: '25.00', sms: '5.00' } },
|
||||
], '60.00');
|
||||
|
||||
const bars = w.findAll('[data-testid="ad-wallet-chart-bar"]');
|
||||
// Первый день выиграла СМС (охристый), второй — Яндекс (teal из палитры Forest).
|
||||
expect(bars[0].attributes('style')).toContain('rgb(138, 109, 59)');
|
||||
expect(bars[1].attributes('style')).toContain('rgb(15, 110, 86)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdWalletCampaignTable from '../../resources/js/components/advertising/AdWalletCampaignTable.vue';
|
||||
import type { AdWalletCampaignRow } from '../../resources/js/api/advertising';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const kampanii: AdWalletCampaignRow[] = [
|
||||
{
|
||||
channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
|
||||
source_id: 13, title: 'кампания №13 «Я5 приёмка»', spent_rub: '126.00', share_percent: 82.4,
|
||||
},
|
||||
{
|
||||
channel: 'sms', channel_title: 'Рассылка СМС', source_type: 'campaign',
|
||||
source_id: 5, title: 'кампания №5 «Августовская»', spent_rub: '27.00', share_percent: 17.6,
|
||||
},
|
||||
];
|
||||
|
||||
function postavit(rows = kampanii, selected: AdWalletCampaignRow | null = null) {
|
||||
return mount(AdWalletCampaignTable, {
|
||||
props: { rows, selected }, global: { plugins: [vuetify] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('AdWalletCampaignTable — куда ушли', () => {
|
||||
it('строка на кампанию, дорогие сверху, с каналом и долей', () => {
|
||||
const w = postavit();
|
||||
|
||||
const rows = w.findAll('[data-testid="ad-wallet-campaign-row"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].text()).toContain('«Я5 приёмка»');
|
||||
expect(rows[0].text()).toContain('Яндекс Аудитория');
|
||||
expect(rows[0].text()).toMatch(/126,00/);
|
||||
expect(rows[0].text()).toContain('82,4');
|
||||
});
|
||||
|
||||
it('клик по строке просит отобрать ленту по этой кампании', async () => {
|
||||
const w = postavit();
|
||||
|
||||
await w.findAll('[data-testid="ad-wallet-campaign-row"]')[0].trigger('click');
|
||||
|
||||
expect(w.emitted('select')?.[0]).toEqual([kampanii[0]]);
|
||||
});
|
||||
|
||||
it('повторный клик по выбранной строке снимает отбор', async () => {
|
||||
const w = postavit(kampanii, kampanii[0]);
|
||||
|
||||
await w.findAll('[data-testid="ad-wallet-campaign-row"]')[0].trigger('click');
|
||||
|
||||
expect(w.emitted('select')?.[0]).toEqual([null]);
|
||||
});
|
||||
|
||||
it('пусто — понятная строка', () => {
|
||||
const w = postavit([]);
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-campaign-empty"]').exists()).toBe(true);
|
||||
expect(w.findAll('[data-testid="ad-wallet-campaign-row"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('больше десяти строк — сперва десять и кнопка «Показать все»', async () => {
|
||||
const mnogo = Array.from({ length: 14 }, (_, i) => ({
|
||||
channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
|
||||
source_id: i + 1, title: `кампания №${i + 1}`,
|
||||
spent_rub: String(100 - i) + '.00', share_percent: 7.1,
|
||||
}));
|
||||
|
||||
const w = postavit(mnogo);
|
||||
expect(w.findAll('[data-testid="ad-wallet-campaign-row"]')).toHaveLength(10);
|
||||
|
||||
await w.find('[data-testid="ad-wallet-campaign-more"]').trigger('click');
|
||||
|
||||
expect(w.findAll('[data-testid="ad-wallet-campaign-row"]')).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdWalletPeriodPicker from '../../resources/js/components/advertising/AdWalletPeriodPicker.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function postavit(from = '2026-07-08', to = '2026-08-06') {
|
||||
return mount(AdWalletPeriodPicker, { props: { from, to }, global: { plugins: [vuetify] } });
|
||||
}
|
||||
|
||||
describe('AdWalletPeriodPicker — выбор периода', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// 6 августа 2026, 08:00 МСК (05:00 UTC).
|
||||
vi.setSystemTime(new Date('2026-08-06T05:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('по умолчанию отмечен «30 дней» — последние 30 суток по Москве', () => {
|
||||
const w = postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-period-30"]').classes()).toContain('vybran');
|
||||
});
|
||||
|
||||
it('«Сегодня» отдаёт один московский день', async () => {
|
||||
const w = postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-period-today"]').trigger('click');
|
||||
|
||||
expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-06', to: '2026-08-06' }]);
|
||||
});
|
||||
|
||||
it('«Вчера» отдаёт вчерашний московский день', async () => {
|
||||
const w = postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-period-yesterday"]').trigger('click');
|
||||
|
||||
expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-05', to: '2026-08-05' }]);
|
||||
});
|
||||
|
||||
it('«7 дней» включает сегодня и шесть предыдущих', async () => {
|
||||
const w = postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-period-7"]').trigger('click');
|
||||
|
||||
expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-07-31', to: '2026-08-06' }]);
|
||||
});
|
||||
|
||||
it('🔴 в 01:30 МСК «сегодня» — уже новый день, а не вчерашний', async () => {
|
||||
// 22:30 UTC 5 августа = 01:30 МСК 6-го. Наивный расчёт по местному поясу дал бы 5-е.
|
||||
vi.setSystemTime(new Date('2026-08-05T22:30:00Z'));
|
||||
const w = postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-period-today"]').trigger('click');
|
||||
|
||||
expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-06', to: '2026-08-06' }]);
|
||||
});
|
||||
|
||||
it('свои даты: перевёрнутый диапазон разворачивается, а не отдаётся вывернутым', async () => {
|
||||
const w = postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-period-custom"]').trigger('click');
|
||||
await w.find('[data-testid="ad-wallet-period-from"]').setValue('2026-08-10');
|
||||
await w.find('[data-testid="ad-wallet-period-to"]').setValue('2026-08-01');
|
||||
await w.find('[data-testid="ad-wallet-period-apply"]').trigger('click');
|
||||
|
||||
expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-01', to: '2026-08-10' }]);
|
||||
});
|
||||
});
|
||||
@@ -2,204 +2,192 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
/**
|
||||
* AdWalletView (T19 — страница «Рекламный кошелёк», план 2026-07-25).
|
||||
* Баланс через fetchWallet() (тот же источник, что и AdWalletHeader), кнопка
|
||||
* «Пополнить» открывает AdWalletTopupDialog, история операций — пометка
|
||||
* «появится позже» (клиентского эндпоинта списка операций нет).
|
||||
*/
|
||||
const { fetchWalletMock, fetchWalletTransactionsMock } = vi.hoisted(() => ({
|
||||
const { fetchWalletMock, fetchReportMock, fetchTransactionsMock } = vi.hoisted(() => ({
|
||||
fetchWalletMock: vi.fn(),
|
||||
// Тип задан явно: без него вывод даёт `never`, и наборы данных ниже не присвоить.
|
||||
// С 06.08.2026 ручка отдаёт не голый список, а ленту + закладки по каналам.
|
||||
fetchWalletTransactionsMock: vi.fn(
|
||||
(): Promise<unknown> => Promise.resolve({ items: [], channels: [], spent_total_rub: '0.00' }),
|
||||
),
|
||||
fetchReportMock: vi.fn(),
|
||||
fetchTransactionsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/advertising', () => ({
|
||||
fetchWallet: fetchWalletMock,
|
||||
fetchWalletTransactions: fetchWalletTransactionsMock,
|
||||
fetchWalletReport: fetchReportMock,
|
||||
fetchWalletTransactions: fetchTransactionsMock,
|
||||
}));
|
||||
|
||||
import AdWalletView from '../../resources/js/views/advertising/AdWalletView.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const koshelek = {
|
||||
solvent: true, balance_rub: '9914.00', frozen_rub: '2507.00', free_rub: '7407.00',
|
||||
frozen_breakdown: [{
|
||||
channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
|
||||
source_id: 19, title: 'кампания №19 «Я3 приёмка»', amount_rub: '833.50',
|
||||
returns_on: '2026-08-07',
|
||||
}],
|
||||
};
|
||||
|
||||
const otchyot = {
|
||||
from: '2026-07-08', to: '2026-08-06', spent_total_rub: '127.24',
|
||||
by_day: [{ day: '2026-08-06', spent_rub: '9.00', by_channel: { yandex: '9.00' } }],
|
||||
by_campaign: [{
|
||||
channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
|
||||
source_id: 13, title: 'кампания №13 «Я5 приёмка»', spent_rub: '126.00', share_percent: 99.0,
|
||||
}],
|
||||
channels: [
|
||||
{ code: 'yandex', title: 'Яндекс Аудитория', spent_rub: '100.24' },
|
||||
{ code: 'sms', title: 'Рассылка СМС', spent_rub: '27.00' },
|
||||
],
|
||||
};
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe('AdWalletView.vue (T19 — страница «Рекламный кошелёк»)', () => {
|
||||
async function postavit() {
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
describe('AdWalletView — страница кошелька', () => {
|
||||
beforeEach(() => {
|
||||
fetchWalletMock.mockReset();
|
||||
fetchWalletMock.mockReset().mockResolvedValue(koshelek);
|
||||
fetchReportMock.mockReset().mockResolvedValue(otchyot);
|
||||
fetchTransactionsMock.mockReset().mockResolvedValue({
|
||||
items: [], has_more: false, next_before_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('показывает баланс/заморожено/свободно из fetchWallet', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: true,
|
||||
balance_rub: '18400.00',
|
||||
frozen_rub: '2500.00',
|
||||
free_rub: '15900.00',
|
||||
it('при открытии дёргает все три ручки', async () => {
|
||||
await postavit();
|
||||
|
||||
expect(fetchWalletMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchReportMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTransactionsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('деньги и расшифровка заморозки видны слева', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/7\s407/);
|
||||
expect(w.findAll('[data-testid="ad-wallet-frozen-row"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('смена периода перечитывает отчёт и ленту, но НЕ баланс', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-period-today"]').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect(fetchReportMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchTransactionsMock).toHaveBeenCalledTimes(2);
|
||||
// Деньги от периода не зависят — лишний запрос был бы враньём и лишней нагрузкой.
|
||||
expect(fetchWalletMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('закладки каналов показывают название и сумму за период', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-tab-yandex"]').text()).toContain('Яндекс Аудитория');
|
||||
expect(w.find('[data-testid="ad-wallet-tab-yandex"]').text()).toMatch(/100,24/);
|
||||
expect(w.find('[data-testid="ad-wallet-tab-all"]').text()).toMatch(/127,24/);
|
||||
});
|
||||
|
||||
it('выбор закладки перезапрашивает и отчёт, и ленту с этим каналом', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-tab-sms"]').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect(fetchReportMock).toHaveBeenLastCalledWith(expect.any(String), expect.any(String), 'sms');
|
||||
expect(fetchTransactionsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ channel: 'sms' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('возврат на «Всё» снимает отбор по каналу', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-tab-sms"]').trigger('click');
|
||||
await flush();
|
||||
await w.find('[data-testid="ad-wallet-tab-all"]').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect(fetchReportMock).toHaveBeenLastCalledWith(expect.any(String), expect.any(String), null);
|
||||
});
|
||||
|
||||
it('движений нет — закладок каналов тоже нет, экран не мусорит', async () => {
|
||||
fetchReportMock.mockResolvedValue({
|
||||
from: '2026-07-08', to: '2026-08-06', spent_total_rub: '0.00',
|
||||
by_day: [], by_campaign: [], channels: [],
|
||||
});
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
fetchTransactionsMock.mockResolvedValue({ items: [], has_more: false, next_before_id: null });
|
||||
|
||||
const w = await postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-tab-all"]').exists()).toBe(false);
|
||||
expect(w.find('[data-testid="ad-wallet-tab-yandex"]').exists()).toBe(false);
|
||||
expect(w.find('[data-testid="ad-wallet-ledger-empty"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('🔴 обвал отчёта не уносит с экрана деньги', async () => {
|
||||
fetchReportMock.mockRejectedValue(new Error('network fail'));
|
||||
|
||||
const w = await postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/7\s407/);
|
||||
expect(w.find('[data-testid="ad-wallet-report-error"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('выбор кампании отбирает ленту и показывает снимаемую метку', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
await w.findAll('[data-testid="ad-wallet-campaign-row"]')[0].trigger('click');
|
||||
await flush();
|
||||
|
||||
expect(fetchTransactionsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ source_type: 'campaign', source_id: 13 }),
|
||||
);
|
||||
expect(w.find('[data-testid="ad-wallet-filter-chip"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('«Показать ещё» дозагружает страницу и не задваивает строки', async () => {
|
||||
const stroka = (id: number) => ({
|
||||
id, type: 'charge', title: 'Списание за рекламу', amount_rub: '-1.00',
|
||||
balance_rub_after: '0.00', moves_balance: true, channel: 'yandex',
|
||||
description: 'Яндекс Аудитория · кампания №13', created_at: '2026-08-06T05:00:00+00:00',
|
||||
});
|
||||
fetchTransactionsMock
|
||||
.mockResolvedValueOnce({ items: [stroka(2)], has_more: true, next_before_id: 2 })
|
||||
.mockResolvedValueOnce({ items: [stroka(1)], has_more: false, next_before_id: null });
|
||||
|
||||
const w = await postavit();
|
||||
|
||||
await w.find('[data-testid="ad-wallet-ledger-more"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-balance"]').text()).toMatch(/18\s400/);
|
||||
expect(w.find('[data-testid="ad-wallet-frozen"]').text()).toMatch(/2\s500/);
|
||||
expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/15\s900/);
|
||||
expect(w.findAll('[data-testid="ad-wallet-ledger-row"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('показывает заметный алерт, когда solvent=false', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: false,
|
||||
balance_rub: '0.00',
|
||||
frozen_rub: '0.00',
|
||||
free_rub: '0.00',
|
||||
});
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-insolvent-alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('кнопка «Пополнить» открывает AdWalletTopupDialog', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: true,
|
||||
balance_rub: '1000.00',
|
||||
frozen_rub: '0.00',
|
||||
free_rub: '1000.00',
|
||||
});
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(false);
|
||||
|
||||
await w.find('[data-testid="ad-wallet-topup-btn"]').trigger('click');
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Приёмка 03.08.2026: у клиента заперты 3 333,36 ₽, и объяснения им не было нигде —
|
||||
* на экране кошелька вместо истории стояло «История операций появится позже».
|
||||
* Теперь история живая: каждое движение подписано человеческими словами и суммой.
|
||||
*/
|
||||
it('показывает живую историю движений с человеческими подписями', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: true,
|
||||
balance_rub: '9991.00',
|
||||
frozen_rub: '3333.36',
|
||||
free_rub: '6657.64',
|
||||
});
|
||||
fetchWalletTransactionsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 2,
|
||||
type: 'freeze',
|
||||
title: 'Деньги зарезервированы',
|
||||
amount_rub: '-3333.36',
|
||||
balance_rub_after: '9991.00',
|
||||
moves_balance: false,
|
||||
channel: 'yandex',
|
||||
description: 'Заморозили под кампанию №6',
|
||||
created_at: '2026-07-30T13:12:00+00:00',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
type: 'topup',
|
||||
title: 'Пополнение кошелька',
|
||||
amount_rub: '9991.00',
|
||||
balance_rub_after: '9991.00',
|
||||
moves_balance: true,
|
||||
channel: null,
|
||||
description: 'Пополнение по счёту',
|
||||
created_at: '2026-07-29T09:00:00+00:00',
|
||||
},
|
||||
],
|
||||
channels: [{ code: 'yandex', title: 'Яндекс Аудитория', spent_rub: '0.00' }],
|
||||
spent_total_rub: '0.00',
|
||||
});
|
||||
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const rows = w.findAll('[data-testid="ad-wallet-history-row"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].text()).toContain('Деньги зарезервированы');
|
||||
expect(rows[0].text()).toContain('кампанию №6');
|
||||
expect(rows[0].text()).toMatch(/3\s333,36/);
|
||||
expect(rows[1].text()).toContain('Пополнение кошелька');
|
||||
|
||||
// Заглушки «появится позже» больше нет.
|
||||
expect(w.find('[data-testid="ad-wallet-history-placeholder"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('пустая история — понятная строка, а не пустое место', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: true,
|
||||
balance_rub: '0.00',
|
||||
frozen_rub: '0.00',
|
||||
free_rub: '0.00',
|
||||
});
|
||||
fetchWalletTransactionsMock.mockResolvedValue({ items: [], channels: [], spent_total_rub: '0.00' });
|
||||
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-history-empty"]').exists()).toBe(true);
|
||||
expect(w.findAll('[data-testid="ad-wallet-history-row"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('история не загрузилась — экран не рушится, суммы на месте', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: true,
|
||||
balance_rub: '1000.00',
|
||||
frozen_rub: '0.00',
|
||||
free_rub: '1000.00',
|
||||
});
|
||||
fetchWalletTransactionsMock.mockRejectedValue(new Error('network fail'));
|
||||
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-balance"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="ad-wallet-history-error"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ошибка загрузки не рушит экран — тихий inline-текст вместо сумм', async () => {
|
||||
it('обвал баланса не рушит экран', async () => {
|
||||
fetchWalletMock.mockRejectedValue(new Error('network fail'));
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-balance"]').exists()).toBe(false);
|
||||
const w = await postavit();
|
||||
|
||||
expect(w.find('[data-testid="ad-wallet-load-error"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('после успешного пополнения (topped-up) баланс перечитывается', async () => {
|
||||
fetchWalletMock.mockResolvedValue({
|
||||
solvent: true,
|
||||
balance_rub: '1000.00',
|
||||
frozen_rub: '0.00',
|
||||
free_rub: '1000.00',
|
||||
});
|
||||
const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
expect(fetchWalletMock).toHaveBeenCalledTimes(1);
|
||||
it('после пополнения перечитывается всё три', async () => {
|
||||
const w = await postavit();
|
||||
|
||||
await w.findComponent({ name: 'AdWalletTopupDialog' }).vm.$emit('topped-up');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(fetchWalletMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchReportMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user