diff --git a/app/app/Http/Controllers/Api/AdvertisingWalletController.php b/app/app/Http/Controllers/Api/AdvertisingWalletController.php index 305bf3c6..949150c3 100644 --- a/app/app/Http/Controllers/Api/AdvertisingWalletController.php +++ b/app/app/Http/Controllers/Api/AdvertisingWalletController.php @@ -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, 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». */ diff --git a/app/app/Services/Advertising/OpisanieDvizheniyaKoshelka.php b/app/app/Services/Advertising/OpisanieDvizheniyaKoshelka.php index 4593235f..663f51c9 100644 --- a/app/app/Services/Advertising/OpisanieDvizheniyaKoshelka.php +++ b/app/app/Services/Advertising/OpisanieDvizheniyaKoshelka.php @@ -105,6 +105,44 @@ final class OpisanieDvizheniyaKoshelka return implode(' · ', $kusochki); } + /** + * Подписи для произвольных пар «канал + вид владельца + номер» — заморозке и таблице + * кампаний нужен тот же сборщик, что и ленте, но входа-летописи у них нет. + * + * @param list $pary + * @return array «канал:вид:номер» → подпись + */ + 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; } diff --git a/app/app/Services/Advertising/OtchyotKoshelka.php b/app/app/Services/Advertising/OtchyotKoshelka.php new file mode 100644 index 00000000..7de89c40 --- /dev/null +++ b/app/app/Services/Advertising/OtchyotKoshelka.php @@ -0,0 +1,210 @@ +}>, + * by_campaign: list, + * channels: list, + * } + */ + 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}> + */ + 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 + */ + 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 + */ + 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 $stroki + * @return list + */ + 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; + } +} diff --git a/app/app/Services/Advertising/RaskryitieZamorozki.php b/app/app/Services/Advertising/RaskryitieZamorozki.php new file mode 100644 index 00000000..187f71c5 --- /dev/null +++ b/app/app/Services/Advertising/RaskryitieZamorozki.php @@ -0,0 +1,82 @@ + + */ + 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 $zamorozki + * @return array + */ + 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(); + } +} diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts index 55fff54b..f8f5b3c1 100644 --- a/app/resources/js/api/advertising.ts +++ b/app/resources/js/api/advertising.ts @@ -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 { +/** День в графике расхода. `by_channel` — коды каналов и потраченное за этот день. */ +export interface AdWalletDay { + day: string; + spent_rub: string; + by_channel: Record; +} + +/** Строка таблицы «Куда ушли». */ +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 { + const { data } = await apiClient.get('/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 { + const params: Record = {}; + 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('/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, }; } diff --git a/app/resources/js/components/advertising/AdWalletCampaignTable.vue b/app/resources/js/components/advertising/AdWalletCampaignTable.vue new file mode 100644 index 00000000..cf0d955a --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletCampaignTable.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/app/resources/js/components/advertising/AdWalletLedger.vue b/app/resources/js/components/advertising/AdWalletLedger.vue new file mode 100644 index 00000000..b97400e2 --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletLedger.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/app/resources/js/components/advertising/AdWalletMoneyColumn.vue b/app/resources/js/components/advertising/AdWalletMoneyColumn.vue new file mode 100644 index 00000000..994b104c --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletMoneyColumn.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/app/resources/js/components/advertising/AdWalletPeriodPicker.vue b/app/resources/js/components/advertising/AdWalletPeriodPicker.vue new file mode 100644 index 00000000..f3bf5ec9 --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletPeriodPicker.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/app/resources/js/components/advertising/AdWalletSpendChart.vue b/app/resources/js/components/advertising/AdWalletSpendChart.vue new file mode 100644 index 00000000..44ba0848 --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletSpendChart.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/app/resources/js/composables/billingFormatters.ts b/app/resources/js/composables/billingFormatters.ts index 116ecbe9..c4878f1a 100644 --- a/app/resources/js/composables/billingFormatters.ts +++ b/app/resources/js/composables/billingFormatters.ts @@ -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 ? '− ' : ''; diff --git a/app/resources/js/views/advertising/AdWalletView.vue b/app/resources/js/views/advertising/AdWalletView.vue index 8b160784..995b1cb5 100644 --- a/app/resources/js/views/advertising/AdWalletView.vue +++ b/app/resources/js/views/advertising/AdWalletView.vue @@ -1,53 +1,80 @@