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:
Дмитрий
2026-08-06 11:18:26 +03:00
parent 6c01d3e08c
commit a709efdee4
23 changed files with 2598 additions and 627 deletions
@@ -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();
}
}
+89 -11
View File
@@ -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>
+2
View File
@@ -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' }]);
});
});
+153 -165
View File
@@ -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);
});
});