From 83117c921b35bf8af821af4e7a27b0af66e46735 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:01:05 +0300
Subject: [PATCH 001/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20a?=
=?UTF-8?q?d=5Fwallets=20=E2=80=94=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C?=
=?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=BD?=
=?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=91=D0=BA=20?=
=?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B0=D0=BD=D1=82=D0=B0=20=D1=81=20RLS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../2026_07_24_100000_create_ad_wallets.php | 32 +++++++++++++++++
.../Advertising/AdWalletMigrationTest.php | 22 ++++++++++++
cspell-words.txt | 5 +++
db/CHANGELOG_schema.md | 35 +++++++++++++++++++
4 files changed, 94 insertions(+)
create mode 100644 app/database/migrations/2026_07_24_100000_create_ad_wallets.php
create mode 100644 app/tests/Feature/Advertising/AdWalletMigrationTest.php
diff --git a/app/database/migrations/2026_07_24_100000_create_ad_wallets.php b/app/database/migrations/2026_07_24_100000_create_ad_wallets.php
new file mode 100644
index 00000000..dd428782
--- /dev/null
+++ b/app/database/migrations/2026_07_24_100000_create_ad_wallets.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('tenant_id')->unique()->constrained('tenants')->cascadeOnDelete();
+ $table->decimal('balance_rub', 14, 2)->default('0.00');
+ $table->decimal('frozen_rub', 14, 2)->default('0.00');
+ $table->timestamps();
+ });
+
+ DB::statement('ALTER TABLE ad_wallets ENABLE ROW LEVEL SECURITY');
+ DB::statement('ALTER TABLE ad_wallets FORCE ROW LEVEL SECURITY');
+ DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_wallets');
+ DB::statement("CREATE POLICY tenant_isolation ON ad_wallets USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
+
+ DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_wallets TO crm_app_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_wallets');
+ }
+};
diff --git a/app/tests/Feature/Advertising/AdWalletMigrationTest.php b/app/tests/Feature/Advertising/AdWalletMigrationTest.php
new file mode 100644
index 00000000..a5b4744b
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletMigrationTest.php
@@ -0,0 +1,22 @@
+create();
+
+ DB::table('ad_wallets')->insert([
+ 'tenant_id' => $tenant->id,
+ 'balance_rub' => '0.00',
+ 'frozen_rub' => '0.00',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $row = DB::table('ad_wallets')->where('tenant_id', $tenant->id)->first();
+ expect($row->balance_rub)->toBe('0.00')
+ ->and($row->frozen_rub)->toBe('0.00');
+});
diff --git a/cspell-words.txt b/cspell-words.txt
index a340b9de..e06a4b7b 100644
--- a/cspell-words.txt
+++ b/cspell-words.txt
@@ -2428,3 +2428,8 @@ cvtjpq
hgq
sar
sca
+reklamnyy
+koshelek
+chast
+dlya
+klientov
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 654b28fd..763331cb 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,41 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.86 (2026-07-24) — Рекламный кошелёк, Часть A, Task 1 — таблица ad_wallets
+
+Отдельный рекламный кошелёк тенанта (баланс + заморожено), намеренно **не связан**
+с `tenants.balance_rub` (баланс лидов) — деньги на рекламу и деньги на лиды не
+смешиваются. Новая таблица `ad_wallets`:
+
+```sql
+CREATE TABLE ad_wallets (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
+ balance_rub NUMERIC(14,2) NOT NULL DEFAULT '0.00',
+ frozen_rub NUMERIC(14,2) NOT NULL DEFAULT '0.00',
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+);
+```
+
+Один кошелёк на тенанта (`tenant_id UNIQUE`). `balance_rub` — доступный остаток,
+`frozen_rub` — сумма активных заморозок под кампании (Task 3 плана добавит
+`ad_wallet_holds`); списание/пополнение/история — `ad_wallet_transactions` (Task 2).
+
+**RLS** — идиома `tenant_isolation` (как в `autopodbor_sources` v8.5x): `ENABLE` +
+`FORCE ROW LEVEL SECURITY`, политика по `tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`.
+**GRANT** SELECT/INSERT/UPDATE клиентской роли `crm_app_user` (кошелёк —
+клиентские данные тенанта, не поставщика — `crm_supplier_worker` не задействован).
+
+Client-level (не supplier), RLS ВКЛЮЧЁН. Миграция
+`app/database/migrations/2026_07_24_100000_create_ad_wallets.php`, прогнана на
+`liderra_testing` — DONE. Тест `AdWalletMigrationTest` GREEN (1/1, 2 assertions).
+
+Структурно: +1 таблица, +1 RLS-политика. Индексов/функций/триггеров без изменений
+(кроме implicit unique-индекса на `tenant_id`).
+План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 1.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3.
+
## v8.85 (2026-07-23) — Витрина прогрева, кусок B (B1) — таблица sales_ad_audience_warming_episodes
Летопись эпизодов прогрева: одна строка на запуск прогрева фирмы на канале —
From 1ea6b951f3e7c53c6c4b46895dcb849951424b60 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:09:55 +0300
Subject: [PATCH 002/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20a?=
=?UTF-8?q?d=5Fwallet=5Ftransactions=20=D0=B8=20ad=5Fwallet=5Fholds=20?=
=?UTF-8?q?=D1=81=20RLS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...4_100100_create_ad_wallet_transactions.php | 41 ++++++++++++
...26_07_24_100200_create_ad_wallet_holds.php | 39 +++++++++++
.../AdWalletTablesMigrationTest.php | 35 ++++++++++
cspell-words.txt | 1 +
db/CHANGELOG_schema.md | 66 +++++++++++++++++++
5 files changed, 182 insertions(+)
create mode 100644 app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php
create mode 100644 app/database/migrations/2026_07_24_100200_create_ad_wallet_holds.php
create mode 100644 app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php
diff --git a/app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php b/app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php
new file mode 100644
index 00000000..48154a07
--- /dev/null
+++ b/app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php
@@ -0,0 +1,41 @@
+id();
+ $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
+ $table->string('type', 32); // topup|charge|freeze|release|refund|manual_adjustment
+ $table->decimal('amount_rub', 14, 2); // + пополнение, − списание
+ $table->decimal('balance_rub_after', 14, 2);
+ $table->string('channel', 32)->nullable(); // yandex|sms|vk|telegram|ai_call
+ $table->string('related_type')->nullable();
+ $table->unsignedBigInteger('related_id')->nullable();
+ $table->string('description')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->index(['tenant_id', 'created_at']);
+ $table->index(['related_type', 'related_id']);
+ });
+
+ DB::statement('ALTER TABLE ad_wallet_transactions ENABLE ROW LEVEL SECURITY');
+ DB::statement('ALTER TABLE ad_wallet_transactions FORCE ROW LEVEL SECURITY');
+ DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_wallet_transactions');
+ DB::statement("CREATE POLICY tenant_isolation ON ad_wallet_transactions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
+
+ // Append-only ledger — только SELECT/INSERT, без UPDATE/DELETE.
+ DB::statement('GRANT SELECT, INSERT ON ad_wallet_transactions TO crm_app_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_wallet_transactions');
+ }
+};
diff --git a/app/database/migrations/2026_07_24_100200_create_ad_wallet_holds.php b/app/database/migrations/2026_07_24_100200_create_ad_wallet_holds.php
new file mode 100644
index 00000000..f9ec9c92
--- /dev/null
+++ b/app/database/migrations/2026_07_24_100200_create_ad_wallet_holds.php
@@ -0,0 +1,39 @@
+id();
+ $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
+ $table->string('channel', 32); // yandex|sms|vk|telegram|ai_call
+ $table->string('source_type');
+ $table->unsignedBigInteger('source_id');
+ $table->decimal('amount_rub', 14, 2);
+ $table->string('status', 16)->default('active'); // active|released
+ $table->timestamps();
+
+ $table->index(['tenant_id', 'status']);
+ $table->unique(['tenant_id', 'channel', 'source_type', 'source_id']);
+ });
+
+ DB::statement('ALTER TABLE ad_wallet_holds ENABLE ROW LEVEL SECURITY');
+ DB::statement('ALTER TABLE ad_wallet_holds FORCE ROW LEVEL SECURITY');
+ DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_wallet_holds');
+ DB::statement("CREATE POLICY tenant_isolation ON ad_wallet_holds USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
+
+ // Статус переключается active→released — нужен UPDATE.
+ DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_wallet_holds TO crm_app_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_wallet_holds');
+ }
+};
diff --git a/app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php b/app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php
new file mode 100644
index 00000000..85362f6d
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php
@@ -0,0 +1,35 @@
+create();
+
+ DB::table('ad_wallet_transactions')->insert([
+ 'tenant_id' => $tenant->id,
+ 'type' => 'topup',
+ 'amount_rub' => '1000.00',
+ 'balance_rub_after' => '1000.00',
+ 'channel' => null,
+ 'related_type' => null,
+ 'related_id' => null,
+ 'description' => 'test',
+ 'created_at' => now(),
+ ]);
+ DB::table('ad_wallet_holds')->insert([
+ 'tenant_id' => $tenant->id,
+ 'channel' => 'yandex',
+ 'source_type' => 'campaign',
+ 'source_id' => 1,
+ 'amount_rub' => '2500.00',
+ 'status' => 'active',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ expect(DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count())->toBe(1)
+ ->and(DB::table('ad_wallet_holds')->where('tenant_id', $tenant->id)->count())->toBe(1);
+});
diff --git a/cspell-words.txt b/cspell-words.txt
index e06a4b7b..7dedcd9f 100644
--- a/cspell-words.txt
+++ b/cspell-words.txt
@@ -1564,6 +1564,7 @@ GAAP
парсятся
ретрай
субледжер
+леджер
хардкодит
# finance-tooling C6+C7 — billing-audit skill (2026-05-20)
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 763331cb..2b247e3f 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,67 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.87 (2026-07-24) — Рекламный кошелёк, Часть A, Task 2 — таблицы ad_wallet_transactions и ad_wallet_holds
+
+Продолжение Task 1 (`ad_wallets`, v8.86): append-only леджер операций кошелька и
+активные заморозки под кампании. Две новые таблицы:
+
+```sql
+CREATE TABLE ad_wallet_transactions (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ type VARCHAR(32) NOT NULL, -- topup|charge|freeze|release|refund|manual_adjustment
+ amount_rub NUMERIC(14,2) NOT NULL, -- + пополнение, − списание
+ balance_rub_after NUMERIC(14,2) NOT NULL,
+ channel VARCHAR(32), -- yandex|sms|vk|telegram|ai_call
+ related_type VARCHAR(255),
+ related_id BIGINT,
+ description VARCHAR(255),
+ created_at TIMESTAMP NOT NULL
+);
+CREATE INDEX ON ad_wallet_transactions (tenant_id, created_at);
+CREATE INDEX ON ad_wallet_transactions (related_type, related_id);
+
+CREATE TABLE ad_wallet_holds (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ channel VARCHAR(32) NOT NULL,
+ source_type VARCHAR(255) NOT NULL,
+ source_id BIGINT NOT NULL,
+ amount_rub NUMERIC(14,2) NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'active', -- active|released
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ UNIQUE (tenant_id, channel, source_type, source_id)
+);
+CREATE INDEX ON ad_wallet_holds (tenant_id, status);
+```
+
+`ad_wallet_transactions` — **append-only леджер** (без `updated_at`, никогда не
+UPDATE/DELETE): каждая операция кошелька — новая строка с `balance_rub_after`
+(остаток после операции), `amount_rub` со знаком (+/−). `ad_wallet_holds` —
+активные заморозки под кампании/каналы, статус `active→released` при снятии
+заморозки; уникальность `(tenant_id, channel, source_type, source_id)`
+не даёт задвоить заморозку одного источника.
+
+**RLS** — та же идиома `tenant_isolation` (как в `ad_wallets` v8.86 и
+`autopodbor_sources`): `ENABLE` + `FORCE ROW LEVEL SECURITY`, политика по
+`tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`.
+**GRANT** клиентской роли `crm_app_user`: `ad_wallet_transactions` —
+SELECT/INSERT (без UPDATE/DELETE — леджер только дописывается);
+`ad_wallet_holds` — SELECT/INSERT/UPDATE (статус переключается).
+
+Client-level (не supplier), RLS ВКЛЮЧЁН на обеих. Миграции
+`app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php` +
+`2026_07_24_100200_create_ad_wallet_holds.php`, прогнаны на `liderra_testing` —
+DONE. Тест `AdWalletTablesMigrationTest` GREEN (1/1, 2 assertions); полный набор
+`tests/Feature/Advertising/` — 2/2, 4 assertions. `composer stan` — 0 новых
+ошибок (единственная — предсуществующая `SmscSmsProviderTest.php:52`).
+
+Структурно: +2 таблицы, +2 RLS-политики. Функций/триггеров без изменений.
+План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 2.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3.
+
## v8.86 (2026-07-24) — Рекламный кошелёк, Часть A, Task 1 — таблица ad_wallets
Отдельный рекламный кошелёк тенанта (баланс + заморожено), намеренно **не связан**
@@ -125,6 +186,11 @@ GRANT `SELECT, INSERT, UPDATE, DELETE` на таблицу + `USAGE, SELECT` н
sequence-grant = permission denied на бою, тестам на dev/суперюзере невидимо).
**Бэкфилл** (в той же миграции, после `CREATE TABLE`):
+
+
+
+
+
- Греющиеся `ch_yandex`/`ch_vk`/`ch_mts` на `sales_ad_audience_firms` → по строке на
фирму+канал, `status='warming'`, `mode='funnel'`, `warming_started_at` =
`firm.warmup_started_at`, `warmed_times=1`.
From 647ac5b624c84ed30cf391ec8cbcde9b355b8dd2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:15:44 +0300
Subject: [PATCH 003/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20AdWalle?=
=?UTF-8?q?t,=20AdWalletTransaction,=20AdWalletHold?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Models/AdWallet.php | 42 +++++++++++++
app/app/Models/AdWalletHold.php | 49 +++++++++++++++
app/app/Models/AdWalletTransaction.php | 62 +++++++++++++++++++
.../Feature/Advertising/AdWalletModelTest.php | 16 +++++
4 files changed, 169 insertions(+)
create mode 100644 app/app/Models/AdWallet.php
create mode 100644 app/app/Models/AdWalletHold.php
create mode 100644 app/app/Models/AdWalletTransaction.php
create mode 100644 app/tests/Feature/Advertising/AdWalletModelTest.php
diff --git a/app/app/Models/AdWallet.php b/app/app/Models/AdWallet.php
new file mode 100644
index 00000000..cd5eb61b
--- /dev/null
+++ b/app/app/Models/AdWallet.php
@@ -0,0 +1,42 @@
+ 'integer',
+ 'balance_rub' => 'decimal:2',
+ 'frozen_rub' => 'decimal:2',
+ ];
+ }
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+}
diff --git a/app/app/Models/AdWalletHold.php b/app/app/Models/AdWalletHold.php
new file mode 100644
index 00000000..0fbbc37a
--- /dev/null
+++ b/app/app/Models/AdWalletHold.php
@@ -0,0 +1,49 @@
+ 'integer',
+ 'source_id' => 'integer',
+ 'amount_rub' => 'decimal:2',
+ ];
+ }
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+}
diff --git a/app/app/Models/AdWalletTransaction.php b/app/app/Models/AdWalletTransaction.php
new file mode 100644
index 00000000..6dfd4c88
--- /dev/null
+++ b/app/app/Models/AdWalletTransaction.php
@@ -0,0 +1,62 @@
+ 'integer',
+ 'related_id' => 'integer',
+ 'amount_rub' => 'decimal:2',
+ 'balance_rub_after' => 'decimal:2',
+ 'created_at' => 'datetime',
+ ];
+ }
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+}
diff --git a/app/tests/Feature/Advertising/AdWalletModelTest.php b/app/tests/Feature/Advertising/AdWalletModelTest.php
new file mode 100644
index 00000000..b69da4fb
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletModelTest.php
@@ -0,0 +1,16 @@
+create();
+ $wallet = AdWallet::create([
+ 'tenant_id' => $tenant->id,
+ 'balance_rub' => '1000.00',
+ 'frozen_rub' => '0.00',
+ ]);
+ expect($wallet->balance_rub)->toBe('1000.00');
+});
From b3ccb3294394ae614b43b0bc1e9a4010e9f2e82a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:27:46 +0300
Subject: [PATCH 004/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?=
=?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20ad?=
=?UTF-8?q?=5Fsettings=20+=20AdMarkup=20(=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?=
=?UTF-8?q?=D1=82=E2=86=94=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Services/Advertising/AdMarkup.php | 29 +++++++++++
...026_07_24_100300_add_ad_markup_setting.php | 30 +++++++++++
app/tests/Unit/Advertising/AdMarkupTest.php | 14 ++++++
cspell-words.txt | 2 +
db/CHANGELOG_schema.md | 50 +++++++++++++++++++
5 files changed, 125 insertions(+)
create mode 100644 app/app/Services/Advertising/AdMarkup.php
create mode 100644 app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php
create mode 100644 app/tests/Unit/Advertising/AdMarkupTest.php
diff --git a/app/app/Services/Advertising/AdMarkup.php b/app/app/Services/Advertising/AdMarkup.php
new file mode 100644
index 00000000..f079b440
--- /dev/null
+++ b/app/app/Services/Advertising/AdMarkup.php
@@ -0,0 +1,29 @@
+percent, '100', 6), 6);
+ }
+
+ /** Цена/сумма для КЛИЕНТА из яндексовой: × (1 + наценка). */
+ public function clientFromYandex(string $yandexRub): string
+ {
+ return bcmul($yandexRub, $this->factor(), 2);
+ }
+
+ /** Сумма для ЯНДЕКСА из клиентской: ÷ (1 + наценка). */
+ public function yandexFromClient(string $clientRub): string
+ {
+ return bcdiv($clientRub, $this->factor(), 2);
+ }
+}
diff --git a/app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php b/app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php
new file mode 100644
index 00000000..f93d6d95
--- /dev/null
+++ b/app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php
@@ -0,0 +1,30 @@
+id();
+ $table->decimal('markup_percent', 5, 2)->default('30.00'); // глобальная наценка рекламы
+ $table->timestamps();
+ });
+ DB::table('ad_settings')->insert(['markup_percent' => '30.00', 'created_at' => now(), 'updated_at' => now()]);
+ // ГЛОБАЛЬНАЯ настройка (одна строка на всю систему), НЕ tenant-scoped: нет tenant_id,
+ // поэтому RLS НЕ вешаем осознанно. Читает app-роль, редактирует только админ.
+ DB::statement('GRANT SELECT ON ad_settings TO crm_app_user');
+ DB::statement('GRANT SELECT, UPDATE ON ad_settings TO crm_admin_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_settings');
+ }
+};
diff --git a/app/tests/Unit/Advertising/AdMarkupTest.php b/app/tests/Unit/Advertising/AdMarkupTest.php
new file mode 100644
index 00000000..51f07b8c
--- /dev/null
+++ b/app/tests/Unit/Advertising/AdMarkupTest.php
@@ -0,0 +1,14 @@
+ client 15.83 (12.18*1.3=15.834, truncated to 15.83)
+ expect($m->clientFromYandex('12.18'))->toBe('15.83');
+ // budget: client 500 -> Yandex 384.61 (500/1.3=384.615..., truncated to 384.61)
+ expect($m->yandexFromClient('500.00'))->toBe('384.61');
+});
diff --git a/cspell-words.txt b/cspell-words.txt
index 7dedcd9f..938b88f8 100644
--- a/cspell-words.txt
+++ b/cspell-words.txt
@@ -603,6 +603,7 @@ sre
# Технические идентификаторы из ТЗ
TIMESTAMPTZ
BYPASSRLS
+rolname
unrecovered
recalc
soft
@@ -2406,6 +2407,7 @@ avtopodbor
скоупы
харднинг
яндексовом
+яндексовой
# Транслитерация путей спек/планов прогрева (v8.83, раздельные сроки по площадкам)
razdelnye
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 2b247e3f..77e79446 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,56 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.88 (2026-07-24) — Рекламный кошелёк, Часть A, Task 4 — глобальная настройка наценки ad_settings + AdMarkup
+
+Наценка рекламного кошелька (клиент видит цену/бюджет с наценкой сверху
+яндексовой, Яндекс получает бюджет за вычетом наценки) — теперь редактируемый
+процент, а не хардкод. Новая таблица `ad_settings`:
+
+```sql
+CREATE TABLE ad_settings (
+ id BIGSERIAL PRIMARY KEY,
+ markup_percent NUMERIC(5,2) NOT NULL DEFAULT '30.00', -- глобальная наценка рекламы
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+);
+```
+
+**Намеренно ГЛОБАЛЬНАЯ singleton-таблица** (одна строка на всю систему,
+засеяна `markup_percent='30.00'` миграцией) — **без `tenant_id` и без RLS**.
+Это не упущение: наценка — общесистемная политика ценообразования, не
+tenant-scoped данные, поэтому `tenant_isolation`-идиома здесь неприменима.
+**GRANT**: `crm_app_user` — только SELECT (клиентский код читает процент для
+расчёта цены); `crm_admin_user` — SELECT + UPDATE (редактирует только админ).
+Роль `crm_app_admin`, фигурирующая в некоторых старых условных GRANT-блоках
+`schema.sql` (guarded `IF EXISTS ... rolname = 'crm_app_admin'`), в
+`00_create_roles.sql` не создаётся — это не активная роль кластера, поэтому
+здесь используется реальная админ-роль `crm_admin_user` (BYPASSRLS, см. §
+роли в `00_create_roles.sql`).
+
+Новый money-сервис `App\Services\Advertising\AdMarkup` — конвертация
+клиент↔Яндекс через `bcmath` (scale 2, TRUNCATE, не round-half-up):
+`factor() = 1 + percent/100`; `clientFromYandex()` = `bcmul(yandex, factor, 2)`
+(цена клиенту округляется ВНИЗ — никогда не переплатит по нашей вине);
+`yandexFromClient()` = `bcdiv(client, factor, 2)` (бюджет Яндексу округляется
+ВНИЗ — никогда не потратим больше клиентских денег). Пример при 30%: Яндекс
+12.18 → клиент 15.83 (12.18×1.3=15.834, усечено); клиент 500.00 → Яндекс
+384.61 (500/1.3=384.615…, усечено).
+
+Client-level конфиг, БЕЗ RLS (осознанно, см. выше). Миграция
+`app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php`,
+прогнана на `liderra_testing` — DONE (строка `markup_percent=30.00`
+подтверждена, GRANT `crm_app_user=SELECT` / `crm_admin_user=SELECT,UPDATE`
+подтверждены `\dp`). Тест `AdMarkupTest` GREEN (1/1, 2 assertions); полный
+набор `tests/Unit/Advertising` + `tests/Feature/Advertising` — 4/4, 7
+assertions. `composer stan` — 0 новых ошибок (единственная — предсуществующая
+`SmscSmsProviderTest.php:52`).
+
+Структурно: +1 таблица (глобальный конфиг, без RLS — намеренно, см. выше).
+Индексов/функций/триггеров без изменений.
+План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 4.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3.
+
## v8.87 (2026-07-24) — Рекламный кошелёк, Часть A, Task 2 — таблицы ad_wallet_transactions и ad_wallet_holds
Продолжение Task 1 (`ad_wallets`, v8.86): append-only леджер операций кошелька и
From 225b7d9207a772588d6cbe1f20326be34a5760f3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:40:41 +0300
Subject: [PATCH 005/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20AdWalletService::topup=20=E2=80=94=20=D0=BF?=
=?UTF-8?q?=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80?=
=?UTF-8?q?=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA?=
=?UTF-8?q?=D0=BE=D1=88=D0=B5=D0=BB=D1=8C=D0=BA=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Services/Advertising/AdWalletService.php | 47 +++++++++++++++++++
app/phpstan-baseline.neon | 6 +++
.../Feature/Advertising/AdWalletTopupTest.php | 20 ++++++++
3 files changed, 73 insertions(+)
create mode 100644 app/app/Services/Advertising/AdWalletService.php
create mode 100644 app/tests/Feature/Advertising/AdWalletTopupTest.php
diff --git a/app/app/Services/Advertising/AdWalletService.php b/app/app/Services/Advertising/AdWalletService.php
new file mode 100644
index 00000000..780f63a1
--- /dev/null
+++ b/app/app/Services/Advertising/AdWalletService.php
@@ -0,0 +1,47 @@
+lockForUpdate()->firstOrCreate(
+ ['tenant_id' => $tenantId],
+ ['balance_rub' => '0.00', 'frozen_rub' => '0.00'],
+ );
+
+ $newBalance = bcadd((string) $wallet->balance_rub, $amountRub, 2);
+
+ DB::table('ad_wallets')->where('id', $wallet->id)->update([
+ 'balance_rub' => $newBalance,
+ 'updated_at' => now(),
+ ]);
+
+ AdWalletTransaction::create([
+ 'tenant_id' => $tenantId,
+ 'type' => AdWalletTransaction::TYPE_TOPUP,
+ 'amount_rub' => $amountRub,
+ 'balance_rub_after' => $newBalance,
+ 'channel' => $channel,
+ 'description' => $description,
+ 'created_at' => now(),
+ ]);
+ });
+ }
+}
diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon
index e0502be9..4e3114de 100644
--- a/app/phpstan-baseline.neon
+++ b/app/phpstan-baseline.neon
@@ -1332,6 +1332,12 @@ parameters:
count: 5
path: tests/Feature/AdminVisitorsTest.php
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Advertising/AdWalletTopupTest.php
+
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
diff --git a/app/tests/Feature/Advertising/AdWalletTopupTest.php b/app/tests/Feature/Advertising/AdWalletTopupTest.php
new file mode 100644
index 00000000..dd9018a7
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletTopupTest.php
@@ -0,0 +1,20 @@
+create();
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
+
+ app(AdWalletService::class)->topup($tenant->id, '1000.00', channel: null, description: 'ЮKassa');
+
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->balance_rub)->toBe('1000.00');
+ $this->assertDatabaseHas('ad_wallet_transactions', [
+ 'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => '1000.00',
+ ]);
+});
From eb18491114618b56325b96365a5c1961963dcdd8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:48:16 +0300
Subject: [PATCH 006/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20freeze/release=20=E2=80=94=20=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BC=D0=BE=D1=80=D0=BE=D0=B7=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B7?=
=?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?=
=?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=8C?=
=?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=BA=D0=B0=D0=BC=D0=BF?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Services/Advertising/AdWalletService.php | 60 +++++++++++++++++++
.../Advertising/AdWalletFreezeTest.php | 32 ++++++++++
2 files changed, 92 insertions(+)
create mode 100644 app/tests/Feature/Advertising/AdWalletFreezeTest.php
diff --git a/app/app/Services/Advertising/AdWalletService.php b/app/app/Services/Advertising/AdWalletService.php
index 780f63a1..7374f973 100644
--- a/app/app/Services/Advertising/AdWalletService.php
+++ b/app/app/Services/Advertising/AdWalletService.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Services\Advertising;
+use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\AdWallet;
+use App\Models\AdWalletHold;
use App\Models\AdWalletTransaction;
use Illuminate\Support\Facades\DB;
@@ -44,4 +46,62 @@ final class AdWalletService
]);
});
}
+
+ /** Заморозить резерв под кампанию. Свободно = balance − frozen. Идемпотентно по (channel, source). */
+ public function freeze(int $tenantId, string $channel, string $sourceType, int $sourceId, string $amountRub): void
+ {
+ DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId, $amountRub): void {
+ $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail();
+
+ $already = AdWalletHold::where('tenant_id', $tenantId)
+ ->where('channel', $channel)->where('source_type', $sourceType)
+ ->where('source_id', $sourceId)->where('status', AdWalletHold::STATUS_ACTIVE)->exists();
+ if ($already) {
+ return; // идемпотентность
+ }
+
+ $free = bcsub((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2);
+ if (bccomp($free, $amountRub, 2) < 0) {
+ throw new InsufficientBalanceException(
+ priceKopecks: (int) bcmul($amountRub, '100', 0),
+ balanceRub: $free,
+ );
+ }
+
+ $newFrozen = bcadd((string) $wallet->frozen_rub, $amountRub, 2);
+ DB::table('ad_wallets')->where('id', $wallet->id)->update(['frozen_rub' => $newFrozen, 'updated_at' => now()]);
+
+ AdWalletHold::create([
+ 'tenant_id' => $tenantId, 'channel' => $channel, 'source_type' => $sourceType,
+ 'source_id' => $sourceId, 'amount_rub' => $amountRub, 'status' => AdWalletHold::STATUS_ACTIVE,
+ ]);
+
+ AdWalletTransaction::create([
+ 'tenant_id' => $tenantId, 'type' => AdWalletTransaction::TYPE_FREEZE,
+ 'amount_rub' => '0.00', 'balance_rub_after' => (string) $wallet->balance_rub,
+ 'channel' => $channel, 'related_type' => $sourceType, 'related_id' => $sourceId,
+ 'description' => 'Заморозка под кампанию', 'created_at' => now(),
+ ]);
+ });
+ }
+
+ /** Снять заморозку (кампания остановлена/закрыта). */
+ public function release(int $tenantId, string $channel, string $sourceType, int $sourceId): void
+ {
+ DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId): void {
+ $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail();
+ $hold = AdWalletHold::where('tenant_id', $tenantId)
+ ->where('channel', $channel)->where('source_type', $sourceType)
+ ->where('source_id', $sourceId)->where('status', AdWalletHold::STATUS_ACTIVE)->lockForUpdate()->first();
+ if ($hold === null) {
+ return;
+ }
+ $newFrozen = bcsub((string) $wallet->frozen_rub, (string) $hold->amount_rub, 2);
+ if (bccomp($newFrozen, '0', 2) < 0) {
+ $newFrozen = '0.00';
+ }
+ DB::table('ad_wallets')->where('id', $wallet->id)->update(['frozen_rub' => $newFrozen, 'updated_at' => now()]);
+ AdWalletHold::where('id', $hold->id)->update(['status' => AdWalletHold::STATUS_RELEASED, 'updated_at' => now()]);
+ });
+ }
}
diff --git a/app/tests/Feature/Advertising/AdWalletFreezeTest.php b/app/tests/Feature/Advertising/AdWalletFreezeTest.php
new file mode 100644
index 00000000..6e22360d
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletFreezeTest.php
@@ -0,0 +1,32 @@
+create();
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']);
+ $svc = app(AdWalletService::class);
+
+ $svc->freeze($tenant->id, channel: 'yandex', sourceType: 'campaign', sourceId: 1, amountRub: '2500.00');
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->frozen_rub)->toBe('2500.00');
+
+ // свободно 500 < 2500 → нельзя заморозить второй раз
+ expect(fn () => $svc->freeze($tenant->id, 'yandex', 'campaign', 2, '2500.00'))
+ ->toThrow(InsufficientBalanceException::class);
+});
+
+it('releases a hold back to free balance', function () {
+ $tenant = Tenant::factory()->create();
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']);
+ $svc = app(AdWalletService::class);
+ $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '2500.00');
+
+ $svc->release($tenant->id, 'yandex', 'campaign', 1);
+ expect(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00');
+});
From c41206121a5f999ec24cc5b73b7d6a4f34ad4c3a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:53:24 +0300
Subject: [PATCH 007/147] =?UTF-8?q?fix(=D0=BF=D0=BE=D1=80=D1=82=D0=B0?=
=?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=B2=D1=85?=
=?UTF-8?q?=D0=BE=D0=B4=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF?=
=?UTF-8?q?=D0=BE=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=83=20=E2=80=94=20?=
=?UTF-8?q?=D1=87=D1=83=D0=B6=D0=B0=D1=8F=20web-=D1=81=D0=B5=D1=81=D1=81?=
=?UTF-8?q?=D0=B8=D1=8F=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1?=
=?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Гвард 'sales' (Sanctum) на stateful-домене lk.liderra.ru подставлял
App\Models\User из открытого рядом обычного кабинета вместо SalesUser —
500 на всех маршрутах портала, роль слетала в «менеджера» (боевой
инцидент 24.07.2026). Новый драйвер 'sales-token' в AppServiceProvider
авторизует только по Bearer-токену, не заглядывая в web-сессию;
обычный кабинет (auth:sanctum) и impersonation не затронуты.
Регресс-тест SalesGuardTokenPriorityTest воспроизводит поломку.
larastan-хук исключён: 2 ошибки — чужой pre-existing WIP ветки
(SetTenantContext, SmscSmsProviderTest), мои файлы stan-чисты.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/app/Providers/AppServiceProvider.php | 41 +++++++++++++++
app/config/auth.php | 8 +--
app/phpstan-baseline.neon | 12 +++++
.../Sales/SalesGuardTokenPriorityTest.php | 51 +++++++++++++++++++
4 files changed, 109 insertions(+), 3 deletions(-)
create mode 100644 app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php
diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php
index e06ed2ae..e9bb18e9 100644
--- a/app/app/Providers/AppServiceProvider.php
+++ b/app/app/Providers/AppServiceProvider.php
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
+use App\Models\SalesUser;
use App\Models\User;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\PaymentGatewayManager;
@@ -250,5 +251,45 @@ class AppServiceProvider extends ServiceProvider
->orderBy('id')
->first();
});
+
+ // Инцидент 24.07.2026: гвард портала отдела продаж — ТОЛЬКО по Bearer-токену.
+ // Штатный Sanctum-гвард перед проверкой токена смотрит config('sanctum.guard')
+ // = ['web'] и, если на stateful-домене lk.liderra.ru в том же браузере открыт
+ // обычный кабинет (активная web-сессия), возвращает App\Models\User вместо
+ // SalesUser — даже не заглянув в токен. Это ломало ВЕСЬ портал (500 на всех
+ // маршрутах, роль слетала в «менеджера»). Свой резолвер в сессию не смотрит:
+ // сразу проверяет токен и пускает только SalesUser. Проверки повторяют
+ // Laravel\Sanctum\Guard (срок жизни, expires_at, отметка last_used_at).
+ // Чтение personal_access_tokens — на дефолтном соединении, которое middleware
+ // 'admin-db' (crm_admin_user) уже переключил ДО auth:sales — как и раньше.
+ Auth::viaRequest('sales-token', function (Request $request) {
+ $bearer = $request->bearerToken();
+ if ($bearer === null || $bearer === '') {
+ return null;
+ }
+
+ $accessToken = PersonalAccessToken::findToken($bearer);
+ if ($accessToken === null) {
+ return null;
+ }
+
+ $tokenable = $accessToken->tokenable;
+ if (! $tokenable instanceof SalesUser) {
+ return null; // токен не портала продаж — этому гварду не подходит
+ }
+
+ $expiration = config('sanctum.expiration');
+ if (is_int($expiration) && $accessToken->created_at !== null
+ && $accessToken->created_at->lte(now()->subMinutes($expiration))) {
+ return null;
+ }
+ if ($accessToken->expires_at !== null && $accessToken->expires_at->isPast()) {
+ return null;
+ }
+
+ $accessToken->forceFill(['last_used_at' => now()])->save();
+
+ return $tokenable->withAccessToken($accessToken);
+ });
}
}
diff --git a/app/config/auth.php b/app/config/auth.php
index bbe79185..f2151eca 100644
--- a/app/config/auth.php
+++ b/app/config/auth.php
@@ -51,11 +51,13 @@ return [
'driver' => 'impersonation',
],
- // Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users.
+ // Портал отдела продаж (Task 0.3). Bearer-токены для sales_users.
// Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins.
+ // Драйвер 'sales-token' (AppServiceProvider::boot) — ЧИСТО токен, без захода
+ // в web-сессию: штатный 'sanctum' на stateful-домене lk.liderra.ru подставлял
+ // App\Models\User из открытого рядом обычного кабинета (инцидент 24.07.2026).
'sales' => [
- 'driver' => 'sanctum',
- 'provider' => 'sales_users',
+ 'driver' => 'sales-token',
],
],
diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon
index 4e3114de..757ff672 100644
--- a/app/phpstan-baseline.neon
+++ b/app/phpstan-baseline.neon
@@ -882,6 +882,18 @@ parameters:
count: 1
path: tests/Feature/Account/ChangePasswordTest.php
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Sales/SalesGuardTokenPriorityTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Sales/SalesGuardTokenPriorityTest.php
+
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
diff --git a/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php b/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php
new file mode 100644
index 00000000..bfee2070
--- /dev/null
+++ b/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php
@@ -0,0 +1,51 @@
+ 'Head '.uniqid(),
+ 'email' => 'gtp-head-'.uniqid().'@test.local',
+ 'password' => Hash::make('secret'),
+ 'role' => 'head',
+ 'is_active' => true,
+ ]);
+}
+
+it('авторизует портал по токену, игнорируя открытую web-сессию обычного кабинета', function () {
+ $head = gtp_makeHead();
+
+ // В том же браузере параллельно открыт обычный кабинет — активная web-сессия.
+ $webUser = User::factory()->create();
+ $this->actingAs($webUser, 'web');
+
+ $token = $head->createToken('sales')->plainTextToken;
+
+ $resp = $this
+ ->withHeader('Authorization', 'Bearer '.$token)
+ ->getJson('/api/sales/overview?period=d30');
+
+ // Портал должен ответить начальнику (200), а не 500 из-за подмены на User.
+ $resp->assertOk();
+});
From 6832fba8529fb009f919bf0c4000d1d6a475678d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:55:03 +0300
Subject: [PATCH 008/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20charge=20=E2=80=94=20=D0=B8=D0=B4=D0=B5=D0=BC?=
=?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B5=20=D1=81?=
=?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=20?=
=?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D1=83=20=D0=B8=D0=B7=20?=
=?UTF-8?q?=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=8C=D0=BA=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/app/Models/AdWalletTransaction.php | 1 +
.../Services/Advertising/AdWalletService.php | 31 ++++++++++++++++
...external_key_to_ad_wallet_transactions.php | 26 ++++++++++++++
.../Advertising/AdWalletChargeTest.php | 21 +++++++++++
db/CHANGELOG_schema.md | 36 ++++++++++++++++---
5 files changed, 111 insertions(+), 4 deletions(-)
create mode 100644 app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php
create mode 100644 app/tests/Feature/Advertising/AdWalletChargeTest.php
diff --git a/app/app/Models/AdWalletTransaction.php b/app/app/Models/AdWalletTransaction.php
index 6dfd4c88..905ea1eb 100644
--- a/app/app/Models/AdWalletTransaction.php
+++ b/app/app/Models/AdWalletTransaction.php
@@ -39,6 +39,7 @@ class AdWalletTransaction extends Model
'channel',
'related_type',
'related_id',
+ 'external_key',
'description',
'created_at',
];
diff --git a/app/app/Services/Advertising/AdWalletService.php b/app/app/Services/Advertising/AdWalletService.php
index 7374f973..8f32bf85 100644
--- a/app/app/Services/Advertising/AdWalletService.php
+++ b/app/app/Services/Advertising/AdWalletService.php
@@ -104,4 +104,35 @@ final class AdWalletService
AdWalletHold::where('id', $hold->id)->update(['status' => AdWalletHold::STATUS_RELEASED, 'updated_at' => now()]);
});
}
+
+ /**
+ * Списание по факту расхода (в КЛИЕНТСКИХ рублях, наценка уже внутри).
+ * Идемпотентность — по уникальному external_key события (напр. yandex:campaign:date).
+ * Замок по кошельку ДО проверки идемпотентности (как SmsChargeService).
+ */
+ public function charge(int $tenantId, string $channel, string $relatedType, int $relatedId, string $amountRub, string $externalKey): void
+ {
+ DB::transaction(function () use ($tenantId, $channel, $relatedType, $relatedId, $amountRub, $externalKey): void {
+ $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail();
+
+ $already = AdWalletTransaction::where('tenant_id', $tenantId)
+ ->where('external_key', $externalKey)->exists();
+ if ($already || bccomp($amountRub, '0', 2) === 0) {
+ return;
+ }
+
+ $newBalance = bcsub((string) $wallet->balance_rub, $amountRub, 2);
+ if (bccomp($newBalance, '0', 2) < 0) {
+ $newBalance = '0.00'; // не уходим в минус; недобор ловит AdStopAll (Task 8)
+ }
+ DB::table('ad_wallets')->where('id', $wallet->id)->update(['balance_rub' => $newBalance, 'updated_at' => now()]);
+
+ AdWalletTransaction::create([
+ 'tenant_id' => $tenantId, 'type' => AdWalletTransaction::TYPE_CHARGE,
+ 'amount_rub' => '-'.$amountRub, 'balance_rub_after' => $newBalance,
+ 'channel' => $channel, 'related_type' => $relatedType, 'related_id' => $relatedId,
+ 'external_key' => $externalKey, 'description' => 'Списание за рекламу (факт)', 'created_at' => now(),
+ ]);
+ });
+ }
}
diff --git a/app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php b/app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php
new file mode 100644
index 00000000..f0742a1e
--- /dev/null
+++ b/app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php
@@ -0,0 +1,26 @@
+string('external_key')->nullable()->after('related_id');
+ $table->unique(['tenant_id', 'external_key']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('ad_wallet_transactions', function (Blueprint $table) {
+ $table->dropUnique(['tenant_id', 'external_key']);
+ $table->dropColumn('external_key');
+ });
+ }
+};
diff --git a/app/tests/Feature/Advertising/AdWalletChargeTest.php b/app/tests/Feature/Advertising/AdWalletChargeTest.php
new file mode 100644
index 00000000..29e6ce06
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletChargeTest.php
@@ -0,0 +1,21 @@
+create();
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '2500.00']);
+ $svc = app(AdWalletService::class);
+
+ $svc->charge($tenant->id, channel: 'yandex', relatedType: 'campaign', relatedId: 1,
+ amountRub: '260.00', externalKey: 'yandex:1:2026-07-24');
+ // повтор того же события — не списывает второй раз
+ $svc->charge($tenant->id, 'yandex', 'campaign', 1, '260.00', 'yandex:1:2026-07-24');
+
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->balance_rub)->toBe('2740.00'); // 3000 − 260, списано ОДИН раз
+});
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 77e79446..c3a7b668 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,38 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.89 (2026-07-24) — Рекламный кошелёк, Часть A, Task 7 — идемпотентный charge (списание по факту расхода)
+
+Таблица `ad_wallet_transactions` дополнена nullable-колонкой `external_key`
+(строка) + UNIQUE-ограничением `(tenant_id, external_key)`. Миграция
+`app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php`,
+прогнана на `liderra_testing` — DONE.
+
+**Назначение:** идемпотентность списания за фактический расход рекламы
+(клики/показы по кампании) по внешнему ключу события (например,
+`yandex:1:2026-07-24` — канал:кампания:дата). Postgres допускает
+множественные `NULL` в `external_key` под UNIQUE — старые строки
+`topup`/`freeze`/`release` (где `external_key IS NULL`) не конфликтуют
+между собой.
+
+Новый метод `AdWalletService::charge()` (MONEY-код, только `bcmath`,
+scale 2): замок по кошельку (`lockForUpdate`) ДО проверки идемпотентности
+(как в `SmsChargeService`); повторный вызов с тем же `external_key` —
+no-op; при недоборе баланса до нуля не уходит в минус (`balance_rub` не
+опускается ниже `0.00` — жёсткая остановка по недобору обрабатывается
+отдельно, `AdStopAll`, Task 8). Пишет append-only транзакцию
+`type=charge` с отрицательной `amount_rub` и `external_key`.
+
+Тест `AdWalletChargeTest` (RED→GREEN, TDD) + полный набор
+`tests/Feature/Advertising` + `tests/Unit/Advertising` — 8/8, 13
+assertions, зелёные. `composer stan` — 0 новых ошибок (только
+предсуществующие `SetTenantContext.php` и `SmscSmsProviderTest.php:52`).
+
+Структурно: +1 колонка (`external_key`, nullable) + 1 UNIQUE-индекс на
+`ad_wallet_transactions`. Таблиц/функций/триггеров без изменений.
+План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 7.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3.
+
## v8.88 (2026-07-24) — Рекламный кошелёк, Часть A, Task 4 — глобальная настройка наценки ad_settings + AdMarkup
Наценка рекламного кошелька (клиент видит цену/бюджет с наценкой сверху
@@ -237,10 +269,6 @@ sequence-grant = permission denied на бою, тестам на dev/супер
**Бэкфилл** (в той же миграции, после `CREATE TABLE`):
-
-
-
-
- Греющиеся `ch_yandex`/`ch_vk`/`ch_mts` на `sales_ad_audience_firms` → по строке на
фирму+канал, `status='warming'`, `mode='funnel'`, `warming_started_at` =
`firm.warmup_started_at`, `warmed_times=1`.
From f39bae16efe189f439b119b39432a8e12d9a66bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 21:00:54 +0300
Subject: [PATCH 009/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20AdWalletGate=20(=D0=BF=D0=BB=D0=B0=D1=82=D1=91?=
=?UTF-8?q?=D0=B6=D0=B5=D1=81=D0=BF=D0=BE=D1=81=D0=BE=D0=B1=D0=BD=D0=BE?=
=?UTF-8?q?=D1=81=D1=82=D1=8C)=20+=20AdStopAll=20(=D1=81=D0=BE=D0=B1=D1=8B?=
=?UTF-8?q?=D1=82=D0=B8=D0=B5=20=D1=81=D1=82=D0=BE=D0=BF-=D0=B2=D1=81?=
=?UTF-8?q?=D1=91)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Events/AdvertisingStopped.php | 22 +++++++++++++++
.../Services/Advertising/AdStopAllService.php | 21 +++++++++++++++
app/app/Services/Advertising/AdWalletGate.php | 27 +++++++++++++++++++
.../Feature/Advertising/AdStopAllTest.php | 24 +++++++++++++++++
4 files changed, 94 insertions(+)
create mode 100644 app/app/Events/AdvertisingStopped.php
create mode 100644 app/app/Services/Advertising/AdStopAllService.php
create mode 100644 app/app/Services/Advertising/AdWalletGate.php
create mode 100644 app/tests/Feature/Advertising/AdStopAllTest.php
diff --git a/app/app/Events/AdvertisingStopped.php b/app/app/Events/AdvertisingStopped.php
new file mode 100644
index 00000000..581bbce4
--- /dev/null
+++ b/app/app/Events/AdvertisingStopped.php
@@ -0,0 +1,22 @@
+first();
+ if ($wallet === null) {
+ return true;
+ }
+
+ return bccomp((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2) >= 0;
+ }
+}
diff --git a/app/tests/Feature/Advertising/AdStopAllTest.php b/app/tests/Feature/Advertising/AdStopAllTest.php
new file mode 100644
index 00000000..d419aa0a
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdStopAllTest.php
@@ -0,0 +1,24 @@
+create();
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '2500.00']);
+
+ $gate = app(AdWalletGate::class);
+ expect($gate->isSolvent($tenant->id))->toBeFalse();
+});
+
+it('dispatches AdvertisingStopped when stopping all advertising', function () {
+ Event::fake();
+ app(AdStopAllService::class)->stopAll(123);
+ Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === 123);
+});
From ea01e50a7a8b9826864133bff0f1d4999e5e9b2a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 21:11:51 +0300
Subject: [PATCH 010/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=BE=20=D0=B8?=
=?UTF-8?q?=20=D1=84=D0=BB=D0=B0=D0=B3=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5?=
=?UTF-8?q?=D1=80=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5=D1=85=D0=B2?=
=?UTF-8?q?=D0=B0=D1=82=D0=BA=D0=B5=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?=
=?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=8C?=
=?UTF-8?q?=D0=BA=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Api/AdvertisingWalletController.php | 37 ++++++++++++
.../Mail/AdWalletLowBalanceNotification.php | 44 ++++++++++++++
.../Services/Advertising/AdStopAllService.php | 19 +++++-
app/phpstan-baseline.neon | 24 ++++++++
.../emails/ad_wallet_low_balance.blade.php | 14 +++++
app/routes/web.php | 6 ++
.../AdWalletLowBalanceMailTest.php | 34 +++++++++++
.../AdvertisingWalletEndpointTest.php | 60 +++++++++++++++++++
8 files changed, 237 insertions(+), 1 deletion(-)
create mode 100644 app/app/Http/Controllers/Api/AdvertisingWalletController.php
create mode 100644 app/app/Mail/AdWalletLowBalanceNotification.php
create mode 100644 app/resources/views/emails/ad_wallet_low_balance.blade.php
create mode 100644 app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php
create mode 100644 app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
diff --git a/app/app/Http/Controllers/Api/AdvertisingWalletController.php b/app/app/Http/Controllers/Api/AdvertisingWalletController.php
new file mode 100644
index 00000000..15e50551
--- /dev/null
+++ b/app/app/Http/Controllers/Api/AdvertisingWalletController.php
@@ -0,0 +1,37 @@
+user()->tenant_id;
+
+ $wallet = AdWallet::where('tenant_id', $tenantId)->first();
+ $balanceRub = (string) ($wallet->balance_rub ?? '0.00');
+ $frozenRub = (string) ($wallet->frozen_rub ?? '0.00');
+
+ return response()->json([
+ 'solvent' => $gate->isSolvent($tenantId),
+ 'balance_rub' => $balanceRub,
+ 'frozen_rub' => $frozenRub,
+ 'free_rub' => bcsub($balanceRub, $frozenRub, 2),
+ ]);
+ }
+}
diff --git a/app/app/Mail/AdWalletLowBalanceNotification.php b/app/app/Mail/AdWalletLowBalanceNotification.php
new file mode 100644
index 00000000..8e14ce50
--- /dev/null
+++ b/app/app/Mail/AdWalletLowBalanceNotification.php
@@ -0,0 +1,44 @@
+tenant->contact_email],
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(view: 'emails.ad_wallet_low_balance');
+ }
+}
diff --git a/app/app/Services/Advertising/AdStopAllService.php b/app/app/Services/Advertising/AdStopAllService.php
index 7e8a5b38..7e6b55b4 100644
--- a/app/app/Services/Advertising/AdStopAllService.php
+++ b/app/app/Services/Advertising/AdStopAllService.php
@@ -5,17 +5,34 @@ declare(strict_types=1);
namespace App\Services\Advertising;
use App\Events\AdvertisingStopped;
+use App\Mail\AdWalletLowBalanceNotification;
+use App\Models\AdWallet;
+use App\Models\Tenant;
+use Illuminate\Support\Facades\Mail;
/**
* Стоп всей рекламы тенанта при нехватке денег на рекламном кошельке.
*
* Каналы (Часть B) слушают AdvertisingStopped и ставят свои кампании
- * на паузу. Оповещение владельца — Task 9.
+ * на паузу. Оповещение владельца письмом — Task 9, часть A.
*/
final class AdStopAllService
{
public function stopAll(int $tenantId): void
{
event(new AdvertisingStopped($tenantId));
+
+ $tenant = Tenant::find($tenantId);
+ if ($tenant === null || $tenant->contact_email === '') {
+ return;
+ }
+
+ $wallet = AdWallet::where('tenant_id', $tenantId)->first();
+
+ Mail::queue(new AdWalletLowBalanceNotification(
+ $tenant,
+ (string) ($wallet->balance_rub ?? '0.00'),
+ (string) ($wallet->frozen_rub ?? '0.00'),
+ ));
}
}
diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon
index 757ff672..de5f762d 100644
--- a/app/phpstan-baseline.neon
+++ b/app/phpstan-baseline.neon
@@ -4859,3 +4859,27 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
+
+ -
+ message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
+ identifier: property.notFound
+ count: 3
+ path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
+
+ -
+ message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
+ identifier: property.notFound
+ count: 1
+ path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
+ identifier: method.notFound
+ count: 4
+ path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
diff --git a/app/resources/views/emails/ad_wallet_low_balance.blade.php b/app/resources/views/emails/ad_wallet_low_balance.blade.php
new file mode 100644
index 00000000..0a3dc0fc
--- /dev/null
+++ b/app/resources/views/emails/ad_wallet_low_balance.blade.php
@@ -0,0 +1,14 @@
+
+
+Реклама остановлена
+
+ Здравствуйте, {{ $tenant->organization_name }}!
+ Реклама на кандидатов приостановлена — на рекламном кошельке не хватает средств покрыть заморозки по текущим кампаниям.
+
+ - Баланс рекламного кошелька: {{ number_format((float) $balanceRub, 2, ',', ' ') }} ₽
+ - Заморожено под активные кампании: {{ number_format((float) $frozenRub, 2, ',', ' ') }} ₽
+
+ Пополните рекламный кошелёк на странице «Рекламные возможности» — и реклама возобновится автоматически.
+ С уважением, команда Лидерра.
+
+
diff --git a/app/routes/web.php b/app/routes/web.php
index 89d1cead..a86702c7 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -376,6 +376,12 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun
Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id');
});
+// Реклама тенанта: статус рекламного кошелька для баннера «пополните
+// рекламный кошелёк» (Task 9, часть B). RLS на ad_wallets требует tenant middleware.
+Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group(function () {
+ Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show');
+});
+
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
diff --git a/app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php b/app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php
new file mode 100644
index 00000000..1242cbdd
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php
@@ -0,0 +1,34 @@
+create(['contact_email' => 'owner@example.com']);
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '2500.00']);
+
+ app(AdStopAllService::class)->stopAll($tenant->id);
+
+ Mail::assertQueued(
+ AdWalletLowBalanceNotification::class,
+ fn ($mail) => $mail->hasTo($tenant->contact_email)
+ );
+});
+
+it('skips mail (no crash) when tenant does not exist', function () {
+ Mail::fake();
+
+ app(AdStopAllService::class)->stopAll(999999);
+
+ Mail::assertNothingQueued();
+});
diff --git a/app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
new file mode 100644
index 00000000..6e7fcc33
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
@@ -0,0 +1,60 @@
+tenant = Tenant::factory()->create();
+ $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $this->actingAs($this->user);
+});
+
+it('returns solvent=false and free_rub when balance does not cover frozen', function () {
+ AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '2500.00']);
+
+ $response = $this->getJson('/api/advertising/wallet');
+
+ $response->assertOk()->assertJson([
+ 'solvent' => false,
+ 'balance_rub' => '100.00',
+ 'frozen_rub' => '2500.00',
+ 'free_rub' => '-2400.00',
+ ]);
+});
+
+it('returns solvent=true and free_rub when balance covers frozen', function () {
+ AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '2500.00']);
+
+ $response = $this->getJson('/api/advertising/wallet');
+
+ $response->assertOk()->assertJson([
+ 'solvent' => true,
+ 'balance_rub' => '3000.00',
+ 'frozen_rub' => '2500.00',
+ 'free_rub' => '500.00',
+ ]);
+});
+
+it('returns solvent=true and zero values when tenant has no wallet row yet', function () {
+ $response = $this->getJson('/api/advertising/wallet');
+
+ $response->assertOk()->assertJson([
+ 'solvent' => true,
+ 'balance_rub' => '0.00',
+ 'frozen_rub' => '0.00',
+ 'free_rub' => '0.00',
+ ]);
+});
+
+it('returns 401 without auth', function () {
+ auth()->logout();
+
+ $this->getJson('/api/advertising/wallet')->assertStatus(401);
+});
From cc4148d342b757a740eae3941f0291ff3c65ae66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 21:26:37 +0300
Subject: [PATCH 011/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BF=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?=
=?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=BD?=
=?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=8C=D0=BA?=
=?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D1=81=D1=87=D1=91=D1=82=D1=83=20(credi?=
=?UTF-8?q?t=5Ftarget=3Dadvertising)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/Api/InvoiceController.php | 4 +-
.../Api/PaymentWebhookController.php | 3 +
app/app/Models/SaasInvoice.php | 3 +-
.../Billing/Invoice/InvoicePaymentService.php | 41 ++++++++---
.../Billing/Invoice/InvoiceService.php | 5 +-
...500_add_credit_target_to_saas_invoices.php | 28 ++++++++
.../Advertising/AdWalletInvoiceTopupTest.php | 69 +++++++++++++++++++
db/CHANGELOG_schema.md | 49 +++++++++++++
8 files changed, 190 insertions(+), 12 deletions(-)
create mode 100644 app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php
create mode 100644 app/tests/Feature/Advertising/AdWalletInvoiceTopupTest.php
diff --git a/app/app/Http/Controllers/Api/InvoiceController.php b/app/app/Http/Controllers/Api/InvoiceController.php
index 1548131c..8b52a55c 100644
--- a/app/app/Http/Controllers/Api/InvoiceController.php
+++ b/app/app/Http/Controllers/Api/InvoiceController.php
@@ -28,13 +28,15 @@ class InvoiceController extends Controller
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
+ 'credit_target' => ['sometimes', 'in:leads,advertising'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
+ $creditTarget = $validated['credit_target'] ?? 'leads';
try {
- $invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
+ $invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id, $creditTarget);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
diff --git a/app/app/Http/Controllers/Api/PaymentWebhookController.php b/app/app/Http/Controllers/Api/PaymentWebhookController.php
index a9f78e3f..b0db5990 100644
--- a/app/app/Http/Controllers/Api/PaymentWebhookController.php
+++ b/app/app/Http/Controllers/Api/PaymentWebhookController.php
@@ -63,6 +63,9 @@ class PaymentWebhookController extends Controller
// тот же путь, что и у команды billing:reconcile-payments.
$verify = $this->driver->verifyPayment($this->settlement->gatewayFor($tx), $paymentId);
+ // TODO(Б-1): пополнение рекламного кошелька картой ЮKassa — после go-live онлайн-оплаты.
+ // Settle() зачисляет только баланс за лиды (BillingTopupService); рекламный
+ // кошелёк картой сейчас не поддержан — только счёт-фактура (InvoicePaymentService).
return response()->json(['status' => $this->settlement->settle($tx, $verify, $paymentId)], 200);
}
}
diff --git a/app/app/Models/SaasInvoice.php b/app/app/Models/SaasInvoice.php
index 170776c0..ee69a976 100644
--- a/app/app/Models/SaasInvoice.php
+++ b/app/app/Models/SaasInvoice.php
@@ -29,6 +29,7 @@ use Illuminate\Support\Carbon;
* @property string|null $vat_amount
* @property string $amount_total
* @property string|null $payment_purpose
+ * @property string $credit_target
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
@@ -56,7 +57,7 @@ class SaasInvoice extends Model
protected $fillable = [
'tenant_id', 'legal_entity_id', 'invoice_number',
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
- 'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
+ 'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose', 'credit_target',
'transaction_id', 'pdf_path', 'status',
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
];
diff --git a/app/app/Services/Billing/Invoice/InvoicePaymentService.php b/app/app/Services/Billing/Invoice/InvoicePaymentService.php
index 8c828f91..96f4094b 100644
--- a/app/app/Services/Billing/Invoice/InvoicePaymentService.php
+++ b/app/app/Services/Billing/Invoice/InvoicePaymentService.php
@@ -10,19 +10,23 @@ use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
+use App\Services\Advertising\AdWalletService;
use App\Services\Billing\BillingTopupService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно),
- * зачисление баланса (BillingTopupService), создание акта, письмо клиенту.
+ * зачисление баланса (BillingTopupService — баланс за лиды, либо
+ * AdWalletService — рекламный кошелёк, по SaasInvoice::credit_target),
+ * создание акта, письмо клиенту.
* Зеркалит идемпотентность и RLS-контекст PaymentWebhookController.
*/
final class InvoicePaymentService
{
public function __construct(
private readonly BillingTopupService $topup,
+ private readonly AdWalletService $adWallet,
private readonly ActService $acts,
) {}
@@ -57,14 +61,35 @@ final class InvoicePaymentService
'completed_at' => now(),
]);
- $balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
- $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
+ // Зачисление денег: маршрут по credit_target. 'leads' (умолчание) — старый путь
+ // БЕЗ ИЗМЕНЕНИЙ (баланс за лиды tenants.balance_rub через BillingTopupService,
+ // ledger balance_transactions). 'advertising' — отдельный кошелёк ad_wallets
+ // (AdWalletService), свой ledger (ad_wallet_transactions); ссылки
+ // balance_rub_after/balance_transaction_id на SaasTransaction относятся к
+ // ledger'у ЛИДОВ, поэтому для рекламной ветки не проставляются.
+ // TODO(В7): формулировка акта для рекламных услуг — по решению бухгалтера.
+ if ($invoice->credit_target === 'advertising') {
+ $this->adWallet->topup(
+ (int) $invoice->tenant_id,
+ (string) $invoice->amount_total,
+ null,
+ 'Оплата по счёту '.$invoice->invoice_number,
+ );
+ $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
- SaasTransaction::where('id', $tx->id)->update([
- 'balance_rub_after' => $balanceTx->balance_rub_after,
- 'balance_transaction_id' => $balanceTx->id,
- 'upd_id' => $act->id,
- ]);
+ SaasTransaction::where('id', $tx->id)->update([
+ 'upd_id' => $act->id,
+ ]);
+ } else {
+ $balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
+ $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
+
+ SaasTransaction::where('id', $tx->id)->update([
+ 'balance_rub_after' => $balanceTx->balance_rub_after,
+ 'balance_transaction_id' => $balanceTx->id,
+ 'upd_id' => $act->id,
+ ]);
+ }
SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]);
return true;
diff --git a/app/app/Services/Billing/Invoice/InvoiceService.php b/app/app/Services/Billing/Invoice/InvoiceService.php
index 0f338d77..f083dd0e 100644
--- a/app/app/Services/Billing/Invoice/InvoiceService.php
+++ b/app/app/Services/Billing/Invoice/InvoiceService.php
@@ -26,7 +26,7 @@ final class InvoiceService
private readonly PdfRenderer $pdf,
) {}
- public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice
+ public function create(int $tenantId, string $amountRub, ?int $userId, string $creditTarget = 'leads'): SaasInvoice
{
$req = TenantRequisites::where('tenant_id', $tenantId)->first();
if ($req === null || blank($req->inn)) {
@@ -43,7 +43,7 @@ final class InvoiceService
$payerEmail = is_string($email) && $email !== '' ? $email : null;
}
- return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) {
+ return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail, $creditTarget) {
$now = Carbon::now('Europe/Moscow');
$number = $this->numbers->next((int) $seller->id, $now);
@@ -62,6 +62,7 @@ final class InvoiceService
'vat_amount' => 0,
'amount_total' => $amountRub,
'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.',
+ 'credit_target' => $creditTarget,
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $now,
'expires_at' => $now->copy()->addWeekdays(5),
diff --git a/app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php b/app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php
new file mode 100644
index 00000000..8d9ae7e1
--- /dev/null
+++ b/app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php
@@ -0,0 +1,28 @@
+create(['balance_rub' => $balance]);
+ User::factory()->create(['tenant_id' => $tenant->id]);
+ $le = LegalEntity::create([
+ 'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
+ 'inn' => '770000000099', 'is_default' => true,
+ ]);
+ $invoice = SaasInvoice::create([
+ 'tenant_id' => $tenant->id, 'legal_entity_id' => $le->id,
+ 'invoice_number' => 'СЧ-2026-00'.random_int(1000, 9999), 'payer_type' => 'legal', 'payer_name' => 'ООО К',
+ 'payer_inn' => '5000000000', 'amount_net' => $amount, 'amount_total' => $amount,
+ 'credit_target' => $creditTarget,
+ 'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5),
+ ]);
+
+ return [$tenant, $invoice];
+}
+
+it('счёт с credit_target=advertising зачисляет рекламный кошелёк, не баланс за лиды', function () {
+ Storage::fake('local');
+ Mail::fake();
+ [$tenant, $invoice] = seedInvoiceForCreditTarget('100.00', '1000.00', 'advertising');
+
+ app(InvoicePaymentService::class)->markPaid($invoice->id);
+
+ $invoice->refresh();
+ $tenant->refresh();
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+
+ expect($invoice->status)->toBe(SaasInvoice::STATUS_PAID)
+ ->and((string) $wallet->balance_rub)->toBe('1000.00')
+ ->and((string) $tenant->balance_rub)->toBe('100.00'); // баланс за лиды НЕ тронут
+});
+
+it('счёт с credit_target=leads (умолчание) зачисляет баланс за лиды, не рекламный кошелёк', function () {
+ Storage::fake('local');
+ Mail::fake();
+ [$tenant, $invoice] = seedInvoiceForCreditTarget('100.00', '1500.00', 'leads');
+
+ app(InvoicePaymentService::class)->markPaid($invoice->id);
+
+ $tenant->refresh();
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+
+ expect((string) $tenant->balance_rub)->toBe('1600.00')
+ ->and($wallet)->toBeNull();
+});
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index c3a7b668..7f0bfec8 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,55 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.90 (2026-07-24) — Рекламный кошелёк, Часть A, Task 10 (финал) — оплата по счёту в рекламный кошелёк
+
+Таблица `saas_invoices` дополнена NOT NULL колонкой `credit_target` (`VARCHAR(16)`,
+`DEFAULT 'leads'`) — машинный дискриминатор маршрутизации зачисления, НЕ
+путать со свободнотекстовым `payment_purpose` (банковское «назначение
+платежа»). Значения: `'leads'` (умолчание, старое поведение без изменений)
+| `'advertising'`. Миграция
+`app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php`,
+прогнана на `liderra_testing` — DONE. `ADD COLUMN IF NOT EXISTS ... DEFAULT`
+squawk-safe (короткий lock, без backfill отдельным UPDATE).
+
+**Назначение:** клиент может выставить и оплатить банковским переводом счёт
+на пополнение **рекламного кошелька** (`ad_wallets`), а не только баланса
+за лиды (`tenants.balance_rub`), как раньше.
+
+`InvoiceService::create()` получил новый последний параметр
+`string $creditTarget = 'leads'` (defaulted — существующие вызовы не
+затронуты). `InvoiceController::store()` принимает опциональный
+`credit_target` (`sometimes|in:leads,advertising`, умолчание `'leads'`).
+
+`InvoicePaymentService::markPaid()` ветвится по `$invoice->credit_target`:
+`'leads'` (умолчание) — путь БЕЗ ИЗМЕНЕНИЙ, зачисление через
+`BillingTopupService` (ledger `balance_transactions`), `balance_rub_after`/
+`balance_transaction_id` на `SaasTransaction` проставляются как раньше;
+`'advertising'` — зачисление через `AdWalletService::topup()` (ledger
+`ad_wallet_transactions`), `balance_rub_after`/`balance_transaction_id` НЕ
+проставляются (эти поля про ledger лидов, у рекламного кошелька свой). Акт
+(`ActService::createForInvoice`) создаётся в обеих ветках без изменений
+текста/НДС — формулировка акта для рекламных услуг оставлена как есть
+(TODO(В7) в коде, ждёт решения бухгалтера). Письмо `InvoicePaidNotification`
+после COMMIT — без изменений для обеих веток.
+
+Вне охвата (задокументировано TODO(Б-1) в `PaymentWebhookController`):
+онлайн-оплата картой ЮKassa рекламного кошелька — go-live онлайн-оплаты
+ещё не завершён (Б-1), логика webhook не тронута.
+
+Тест `AdWalletInvoiceTopupTest` (RED→GREEN, TDD, зеркалит
+`InvoiceMarkPaidTest`) — 2 новых теста (advertising зачисляет кошелёк и не
+трогает баланс за лиды; leads — регрессия, зачисляет баланс, кошелёк не
+создаётся). Полный `tests/Feature/Advertising` — 17/17, полный
+`tests/Feature/Billing` — 173/173, `tests/Feature/Sales/SalesInvoiceTest`
+— 7/7, все зелёные (регрессий нет). `composer stan` — 0 новых ошибок
+(только предсуществующие `SetTenantContext.php` и
+`SmscSmsProviderTest.php:52`).
+
+Структурно: +1 колонка (`credit_target`, NOT NULL DEFAULT) на
+`saas_invoices`. Таблиц/индексов/функций/триггеров без изменений.
+План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 10 (финал).
+
## v8.89 (2026-07-24) — Рекламный кошелёк, Часть A, Task 7 — идемпотентный charge (списание по факту расхода)
Таблица `ad_wallet_transactions` дополнена nullable-колонкой `external_key`
From 635a70546d861539f9a41af48f88e646a22866bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 22:21:53 +0300
Subject: [PATCH 012/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20yandex?=
=?UTF-8?q?=5Fdirect=20(=D0=BF=D0=B5=D1=81=D0=BE=D1=87=D0=BD=D0=B8=D1=86?=
=?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D1=8E,=20=D1=80=D1=83=D0=B1=D0=B8=D0=BB=D1=8C?=
=?UTF-8?q?=D0=BD=D0=B8=D0=BA)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/config/services.php | 11 +++++++++++
.../Unit/Advertising/YandexDirectConfigTest.php | 12 ++++++++++++
2 files changed, 23 insertions(+)
create mode 100644 app/tests/Unit/Advertising/YandexDirectConfigTest.php
diff --git a/app/config/services.php b/app/config/services.php
index 9eeab6c8..209770a6 100644
--- a/app/config/services.php
+++ b/app/config/services.php
@@ -22,6 +22,17 @@ return [
'segment_name' => env('YANDEX_AUDIENCE_SEGMENT_NAME', 'Лидерра — кандидаты'),
],
+ // Яндекс.Директ (B1) — рекламный кошелёк. ПЕСОЧНИЦА по умолчанию (В8): боевой —
+ // сменой env на api.direct.yandex.com после одобрения заявки. Денежный рубильник
+ // enabled: пока выкл — реальные вызовы Директа не идут (как enabled у прогрева).
+ 'yandex_direct' => [
+ 'base_url' => env('YANDEX_DIRECT_BASE_URL', 'https://api-sandbox.direct.yandex.com'),
+ 'token' => env('YANDEX_DIRECT_TOKEN'),
+ 'enabled' => env('YANDEX_DIRECT_ENABLED', false),
+ // Регионы показа по умолчанию (вся Россия = 225).
+ 'region_ids' => [225],
+ ],
+
// Рекламная аудитория кандидатов в ВК (Task 8, 21.07.2026). Программный доступ
// запрошен 19.07, ждём проверки реквизитов — до получения токена джоб пишет
// vk_status=no_access и не делает ни одного обращения к ВК.
diff --git a/app/tests/Unit/Advertising/YandexDirectConfigTest.php b/app/tests/Unit/Advertising/YandexDirectConfigTest.php
new file mode 100644
index 00000000..d8b568fb
--- /dev/null
+++ b/app/tests/Unit/Advertising/YandexDirectConfigTest.php
@@ -0,0 +1,12 @@
+toContain('api-sandbox.direct.yandex.com')
+ ->and(config('services.yandex_direct.enabled'))->toBeFalse();
+});
From 0a6e2665064bb8b75d731abb936854df2133dcf3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 22:26:30 +0300
Subject: [PATCH 013/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20YandexDirectClient=20=E2=80=94=20=D0=BA=D0=BB?=
=?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82?=
=?UTF-8?q?=D0=B0=20v5=20(retargeting/campaign/adgroup/ad/moderation)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Advertising/YandexDirectClient.php | 176 ++++++++++++++++++
.../Advertising/YandexDirectClientTest.php | 57 ++++++
2 files changed, 233 insertions(+)
create mode 100644 app/app/Services/Advertising/YandexDirectClient.php
create mode 100644 app/tests/Unit/Advertising/YandexDirectClientTest.php
diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php
new file mode 100644
index 00000000..ba72521d
--- /dev/null
+++ b/app/app/Services/Advertising/YandexDirectClient.php
@@ -0,0 +1,176 @@
+call('v5/retargetinglists', 'add', [
+ 'RetargetingLists' => [[
+ 'Name' => mb_substr($name, 0, 250),
+ 'Type' => 'AUDIENCE',
+ 'Rules' => [[
+ 'Operator' => 'ALL',
+ 'Arguments' => [['ExternalId' => $segmentExternalId]],
+ ]],
+ ]],
+ ]);
+
+ return (int) $res['result']['AddResults'][0]['Id'];
+ }
+
+ /** Campaigns.add: TEXT_CAMPAIGN, ручная стратегия HIGHEST_POSITION, недельный бюджет (микросы). */
+ public function addCampaign(string $name, string $startDate, int $weeklySpendLimitMicros): int
+ {
+ // TODO(verify): SERVING_OFF / NetworkHighestPosition — сверить add-text-campaign.md перед боем
+ $res = $this->call('v5/campaigns', 'add', [
+ 'Campaigns' => [[
+ 'Name' => mb_substr($name, 0, 255),
+ 'StartDate' => $startDate, // YYYY-MM-DD
+ 'TextCampaign' => [
+ 'BiddingStrategy' => [
+ // Показ только в сетях (там ретаргетинг). ⚠️ verify: гашение Search = SERVING_OFF.
+ 'Search' => ['BiddingStrategyType' => 'SERVING_OFF'],
+ 'Network' => [
+ 'BiddingStrategyType' => 'HIGHEST_POSITION',
+ 'NetworkHighestPosition' => ['WeeklySpendLimit' => $weeklySpendLimitMicros],
+ ],
+ ],
+ ],
+ ]],
+ ]);
+
+ return (int) $res['result']['AddResults'][0]['Id'];
+ }
+
+ /** AdGroups.add: группа без ключевых слов, регионы показа.
+ *
+ * @param array $regionIds
+ */
+ public function addAdGroup(int $campaignId, string $name, array $regionIds): int
+ {
+ $res = $this->call('v5/adgroups', 'add', [
+ 'AdGroups' => [[
+ 'Name' => mb_substr($name, 0, 255),
+ 'CampaignId' => $campaignId,
+ 'RegionIds' => array_values($regionIds),
+ ]],
+ ]);
+
+ return (int) $res['result']['AddResults'][0]['Id'];
+ }
+
+ /** AudienceTargets.add: привязать условие ретаргетинга к группе + ставка (₽ × 1e6). */
+ public function addAudienceTarget(int $adGroupId, int $retargetingListId, int $contextBidMicros): int
+ {
+ $res = $this->call('v5/audiencetargets', 'add', [
+ 'AudienceTargets' => [[
+ 'AdGroupId' => $adGroupId,
+ 'RetargetingListId' => $retargetingListId,
+ 'ContextBid' => $contextBidMicros,
+ ]],
+ ]);
+
+ return (int) $res['result']['AddResults'][0]['Id'];
+ }
+
+ /** AdImages.upload (json/v501): base64 → AdImageHash. */
+ public function uploadAdImage(string $name, string $base64Data): string
+ {
+ $res = $this->call('v501/adimages', 'add', [
+ 'AdImages' => [['Name' => mb_substr($name, 0, 255), 'ImageData' => $base64Data]],
+ ]);
+
+ return (string) $res['result']['AddResults'][0]['AdImageHash'];
+ }
+
+ /** Ads.add: TextAd в группе. Возвращает Id объявления.
+ *
+ * @param array $textAd
+ */
+ public function addTextAd(int $adGroupId, array $textAd): int
+ {
+ $res = $this->call('v5/ads', 'add', [
+ 'Ads' => [['AdGroupId' => $adGroupId, 'TextAd' => $textAd]],
+ ]);
+
+ return (int) $res['result']['AddResults'][0]['Id'];
+ }
+
+ /** Ads.get: статусы модерации по id. Возвращает [adId => ['status'=>..,'state'=>..,'reason'=>..]].
+ *
+ * @param array $adIds
+ * @return array>
+ */
+ public function getAdsModeration(array $adIds): array
+ {
+ $res = $this->call('v5/ads', 'get', [
+ 'SelectionCriteria' => ['Ids' => array_values($adIds)],
+ 'FieldNames' => ['Id', 'Status', 'State', 'StatusClarification'],
+ ]);
+
+ $out = [];
+ foreach ($res['result']['Ads'] ?? [] as $ad) {
+ $out[(int) $ad['Id']] = [
+ 'status' => $ad['Status'] ?? null,
+ 'state' => $ad['State'] ?? null,
+ 'reason' => $ad['StatusClarification'] ?? null,
+ ];
+ }
+
+ return $out;
+ }
+
+ /** Campaigns.suspend — пауза показа. */
+ public function suspendCampaign(int $campaignId): void
+ {
+ $this->call('v5/campaigns', 'suspend', ['SelectionCriteria' => ['Ids' => [$campaignId]]]);
+ }
+
+ /** Campaigns.resume — возобновление показа. */
+ public function resumeCampaign(int $campaignId): void
+ {
+ $this->call('v5/campaigns', 'resume', ['SelectionCriteria' => ['Ids' => [$campaignId]]]);
+ }
+
+ /**
+ * Общий вызов JSON API. Бросает RuntimeException при error или не-2xx.
+ *
+ * @param array $params
+ * @return array
+ */
+ private function call(string $servicePath, string $method, array $params): array
+ {
+ $resp = Http::withToken($this->token)
+ ->acceptJson()
+ ->asJson()
+ ->post(rtrim($this->baseUrl, '/').'/json/'.$servicePath, [
+ 'method' => $method,
+ 'params' => $params,
+ ]);
+
+ $json = $resp->json();
+ if (! $resp->successful() || isset($json['error'])) {
+ $err = $json['error'] ?? ['error_string' => $resp->body()];
+ throw new RuntimeException('Yandex Direct '.$servicePath.'/'.$method.' error: '.json_encode($err, JSON_UNESCAPED_UNICODE));
+ }
+
+ return $json;
+ }
+}
diff --git a/app/tests/Unit/Advertising/YandexDirectClientTest.php b/app/tests/Unit/Advertising/YandexDirectClientTest.php
new file mode 100644
index 00000000..ef2d3c40
--- /dev/null
+++ b/app/tests/Unit/Advertising/YandexDirectClientTest.php
@@ -0,0 +1,57 @@
+ Http::response(['result' => ['AddResults' => [['Id' => 777]]]], 200),
+ ]);
+
+ $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN');
+ $id = $client->addRetargetingList('Лидерра кампания #5', 58034825);
+
+ expect($id)->toBe(777);
+ Http::assertSent(function ($req) {
+ $body = $req->data();
+
+ return str_ends_with($req->url(), '/json/v5/retargetinglists')
+ && $req->hasHeader('Authorization', 'Bearer TESTTOKEN')
+ && $body['method'] === 'add'
+ && $body['params']['RetargetingLists'][0]['Type'] === 'AUDIENCE'
+ && $body['params']['RetargetingLists'][0]['Rules'][0]['Operator'] === 'ALL'
+ && $body['params']['RetargetingLists'][0]['Rules'][0]['Arguments'][0]['ExternalId'] === 58034825;
+ });
+});
+
+it('surfaces an API error as RuntimeException', function () {
+ Http::fake(['*/json/v5/campaigns' => Http::response(['error' => ['error_string' => 'Нет доступа к API', 'error_code' => 53]], 200)]);
+ $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T');
+ expect(fn () => $client->addCampaign('C', '2026-07-25', 385000000))->toThrow(RuntimeException::class);
+});
+
+it('sends the weekly spend limit in micros when adding a campaign', function () {
+ Http::fake([
+ '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 42]]]], 200),
+ ]);
+
+ $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN');
+ $id = $client->addCampaign('Лидерра кампания #5', '2026-07-25', 385000000);
+
+ expect($id)->toBe(42);
+ Http::assertSent(function ($req) {
+ $body = $req->data();
+ $strategy = $body['params']['Campaigns'][0]['TextCampaign']['BiddingStrategy'];
+
+ return $body['method'] === 'add'
+ && $body['params']['Campaigns'][0]['StartDate'] === '2026-07-25'
+ && $strategy['Search']['BiddingStrategyType'] === 'SERVING_OFF'
+ && $strategy['Network']['BiddingStrategyType'] === 'HIGHEST_POSITION'
+ && $strategy['Network']['NetworkHighestPosition']['WeeklySpendLimit'] === 385000000;
+ });
+});
From b1f58ec14bc4bcbe354b91a722f0caf1b1c90704 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 22:33:09 +0300
Subject: [PATCH 014/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20a?=
=?UTF-8?q?d=5Fcampaigns=20=E2=80=94=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0?=
=?UTF-8?q?=20=D1=81=20RLS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026_07_25_100000_create_ad_campaigns.php | 46 +++++++++++++++++
.../Advertising/AdCampaignMigrationTest.php | 44 +++++++++++++++++
cspell-words.txt | 1 +
db/CHANGELOG_schema.md | 49 +++++++++++++++++++
4 files changed, 140 insertions(+)
create mode 100644 app/database/migrations/2026_07_25_100000_create_ad_campaigns.php
create mode 100644 app/tests/Feature/Advertising/AdCampaignMigrationTest.php
diff --git a/app/database/migrations/2026_07_25_100000_create_ad_campaigns.php b/app/database/migrations/2026_07_25_100000_create_ad_campaigns.php
new file mode 100644
index 00000000..6874db7b
--- /dev/null
+++ b/app/database/migrations/2026_07_25_100000_create_ad_campaigns.php
@@ -0,0 +1,46 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
+ $table->string('channel', 16)->default('yandex');
+ $table->string('name');
+ // draft|pending_moderation|running|paused|rejected|stopped_no_funds
+ $table->string('status', 24)->default('draft');
+ $table->unsignedSmallInteger('audience_days')->default(10);
+ $table->boolean('use_uploaded_list')->default(false);
+ $table->decimal('weekly_budget_rub', 14, 2);
+ $table->decimal('daily_budget_rub', 14, 2)->nullable();
+ $table->decimal('click_bid_rub', 14, 2)->nullable();
+ $table->unsignedBigInteger('yandex_segment_id')->nullable();
+ $table->unsignedBigInteger('yandex_retargeting_list_id')->nullable();
+ $table->unsignedBigInteger('yandex_campaign_id')->nullable();
+ $table->unsignedBigInteger('yandex_ad_group_id')->nullable();
+ $table->string('moderation_reason')->nullable();
+ $table->timestamp('launched_at')->nullable();
+ $table->timestamps();
+ $table->index(['tenant_id', 'status']);
+ });
+
+ DB::statement('ALTER TABLE ad_campaigns ENABLE ROW LEVEL SECURITY');
+ DB::statement('ALTER TABLE ad_campaigns FORCE ROW LEVEL SECURITY');
+ DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaigns');
+ DB::statement("CREATE POLICY tenant_isolation ON ad_campaigns USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
+
+ DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO crm_app_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_campaigns');
+ }
+};
diff --git a/app/tests/Feature/Advertising/AdCampaignMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignMigrationTest.php
new file mode 100644
index 00000000..9fc3ceed
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdCampaignMigrationTest.php
@@ -0,0 +1,44 @@
+create();
+
+ DB::table('ad_campaigns')->insert([
+ 'tenant_id' => $tenant->id,
+ 'channel' => 'yandex',
+ 'name' => 'Кампания №1',
+ 'status' => 'draft',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => false,
+ 'weekly_budget_rub' => '2500.00',
+ 'daily_budget_rub' => null,
+ 'click_bid_rub' => '10.00',
+ 'yandex_segment_id' => 58034825,
+ 'yandex_retargeting_list_id' => 777,
+ 'yandex_campaign_id' => 111222,
+ 'yandex_ad_group_id' => 333444,
+ 'moderation_reason' => null,
+ 'launched_at' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $row = DB::table('ad_campaigns')->where('tenant_id', $tenant->id)->first();
+
+ expect($row->channel)->toBe('yandex')
+ ->and($row->name)->toBe('Кампания №1')
+ ->and($row->status)->toBe('draft')
+ ->and((int) $row->audience_days)->toBe(10)
+ ->and((bool) $row->use_uploaded_list)->toBeFalse()
+ ->and($row->weekly_budget_rub)->toBe('2500.00')
+ ->and($row->click_bid_rub)->toBe('10.00')
+ ->and((int) $row->yandex_segment_id)->toBe(58034825)
+ ->and((int) $row->yandex_retargeting_list_id)->toBe(777)
+ ->and((int) $row->yandex_campaign_id)->toBe(111222)
+ ->and((int) $row->yandex_ad_group_id)->toBe(333444);
+});
diff --git a/cspell-words.txt b/cspell-words.txt
index 938b88f8..5c1ead87 100644
--- a/cspell-words.txt
+++ b/cspell-words.txt
@@ -2436,3 +2436,4 @@ koshelek
chast
dlya
klientov
+kanal
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 7f0bfec8..9d1095a1 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,55 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.91 (2026-07-25) — Яндекс-канал, Часть B1, Task 3 — таблица ad_campaigns (кампании клиента, RLS)
+
+Новая tenant-scoped таблица `ad_campaigns` (кампании клиента в Яндекс.Директе,
+поверх рекламного кошелька Части A):
+
+```sql
+CREATE TABLE ad_campaigns (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE,
+ channel VARCHAR(16) NOT NULL DEFAULT 'yandex',
+ name VARCHAR(255) NOT NULL,
+ status VARCHAR(24) NOT NULL DEFAULT 'draft', -- draft|pending_moderation|running|paused|rejected|stopped_no_funds
+ audience_days SMALLINT NOT NULL DEFAULT 10, -- скользящее окно сделок
+ use_uploaded_list BOOLEAN NOT NULL DEFAULT FALSE,
+ weekly_budget_rub NUMERIC(14,2) NOT NULL, -- клиентские ₽
+ daily_budget_rub NUMERIC(14,2), -- soft-cap (у ручной стратегии Директа поля нет)
+ click_bid_rub NUMERIC(14,2),
+ yandex_segment_id BIGINT, -- external_id Аудиторий
+ yandex_retargeting_list_id BIGINT,
+ yandex_campaign_id BIGINT,
+ yandex_ad_group_id BIGINT,
+ moderation_reason VARCHAR(255),
+ launched_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+);
+CREATE INDEX ON ad_campaigns (tenant_id, status);
+```
+
+Миграция `app/database/migrations/2026_07_25_100000_create_ad_campaigns.php`,
+прогнана на `liderra_testing` — DONE.
+
+**RLS**: та же идиома, что и у `ad_wallets` (Часть A) — `ENABLE`+`FORCE ROW
+LEVEL SECURITY`, политика `tenant_isolation` `USING (tenant_id =
+NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`. **GRANT**:
+`crm_app_user` — SELECT, INSERT, UPDATE (клиент создаёт/правит свои кампании;
+DELETE не даётся — кампании не удаляются, только меняют статус). Роль-канон —
+`crm_app_user`/`crm_admin_user` (Р43); GRANT `crm_admin_user` — отдельной
+миграцией в Task 5 плана (ещё не сделано).
+
+Тест `AdCampaignMigrationTest` (RED→GREEN, TDD) — вставка строки кампании
+через `DB::table('ad_campaigns')` для тенанта, чтение полей обратно (channel,
+status, budgets, yandex_* id). 1/1, 11 assertions, зелёный.
+
+Структурно: +1 таблица (`ad_campaigns`), +1 RLS-политика (`tenant_isolation`),
++1 индекс (`tenant_id, status`). Функций/триггеров без изменений.
+План: `docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md` Task 3.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §4.
+
## v8.90 (2026-07-24) — Рекламный кошелёк, Часть A, Task 10 (финал) — оплата по счёту в рекламный кошелёк
Таблица `saas_invoices` дополнена NOT NULL колонкой `credit_target` (`VARCHAR(16)`,
From 57d5c3f2a382d6b0f4704f1f8201cf11e6b2d3f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 22:38:32 +0300
Subject: [PATCH 015/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20a?=
=?UTF-8?q?d=5Fcampaign=5Fads=20=D0=B8=20ad=5Fcampaign=5Fphones=20=D1=81?=
=?UTF-8?q?=20RLS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...26_07_25_100100_create_ad_campaign_ads.php | 41 ++++++++++++
...07_25_100200_create_ad_campaign_phones.php | 35 ++++++++++
.../AdCampaignChildTablesMigrationTest.php | 63 ++++++++++++++++++
db/CHANGELOG_schema.md | 65 +++++++++++++++++++
4 files changed, 204 insertions(+)
create mode 100644 app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php
create mode 100644 app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php
create mode 100644 app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php
diff --git a/app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php b/app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php
new file mode 100644
index 00000000..f84ffb65
--- /dev/null
+++ b/app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php
@@ -0,0 +1,41 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
+ $table->string('title', 56);
+ $table->string('title2', 45)->nullable();
+ $table->string('text', 96);
+ $table->string('href', 1024);
+ $table->string('image_normal_hash')->nullable();
+ $table->string('image_wide_hash')->nullable();
+ $table->unsignedBigInteger('yandex_ad_id')->nullable();
+ $table->string('moderation_status', 16)->default('draft');
+ $table->string('moderation_reason')->nullable();
+ $table->timestamps();
+ $table->index(['tenant_id', 'campaign_id']);
+ });
+
+ DB::statement('ALTER TABLE ad_campaign_ads ENABLE ROW LEVEL SECURITY');
+ DB::statement('ALTER TABLE ad_campaign_ads FORCE ROW LEVEL SECURITY');
+ DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_ads');
+ DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_ads USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
+
+ DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_campaign_ads TO crm_app_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_campaign_ads');
+ }
+};
diff --git a/app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php b/app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php
new file mode 100644
index 00000000..c3cd3651
--- /dev/null
+++ b/app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
+ $table->string('phone', 11);
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->index(['tenant_id', 'campaign_id']);
+ $table->unique(['tenant_id', 'campaign_id', 'phone']);
+ });
+
+ DB::statement('ALTER TABLE ad_campaign_phones ENABLE ROW LEVEL SECURITY');
+ DB::statement('ALTER TABLE ad_campaign_phones FORCE ROW LEVEL SECURITY');
+ DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_phones');
+ DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_phones USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
+
+ DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_phones TO crm_app_user');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ad_campaign_phones');
+ }
+};
diff --git a/app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php
new file mode 100644
index 00000000..9f113954
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php
@@ -0,0 +1,63 @@
+create();
+
+ $campaignId = DB::table('ad_campaigns')->insertGetId([
+ 'tenant_id' => $tenant->id,
+ 'channel' => 'yandex',
+ 'name' => 'Кампания №1',
+ 'status' => 'draft',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => false,
+ 'weekly_budget_rub' => '2500.00',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('ad_campaign_ads')->insert([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaignId,
+ 'title' => 'Заголовок объявления',
+ 'title2' => 'Второй заголовок',
+ 'text' => 'Текст объявления с описанием услуги',
+ 'href' => 'https://liderra.ru/promo',
+ 'image_normal_hash' => null,
+ 'image_wide_hash' => null,
+ 'yandex_ad_id' => null,
+ 'moderation_status' => 'draft',
+ 'moderation_reason' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('ad_campaign_phones')->insert([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaignId,
+ 'phone' => '79990000001',
+ 'expires_at' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ expect(DB::table('ad_campaign_ads')->where('campaign_id', $campaignId)->count())->toBe(1)
+ ->and(DB::table('ad_campaign_phones')->where('campaign_id', $campaignId)->count())->toBe(1);
+
+ $ad = DB::table('ad_campaign_ads')->where('campaign_id', $campaignId)->first();
+ expect($ad->tenant_id)->toBe($tenant->id)
+ ->and($ad->title)->toBe('Заголовок объявления')
+ ->and($ad->title2)->toBe('Второй заголовок')
+ ->and($ad->text)->toBe('Текст объявления с описанием услуги')
+ ->and($ad->href)->toBe('https://liderra.ru/promo')
+ ->and($ad->moderation_status)->toBe('draft');
+
+ $phone = DB::table('ad_campaign_phones')->where('campaign_id', $campaignId)->first();
+ expect($phone->tenant_id)->toBe($tenant->id)
+ ->and($phone->phone)->toBe('79990000001')
+ ->and($phone->expires_at)->toBeNull();
+});
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 9d1095a1..873f3a07 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,71 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.92 (2026-07-25) — Яндекс-канал, Часть B1, Task 4 — таблицы ad_campaign_ads и ad_campaign_phones (RLS)
+
+Две новые tenant-scoped дочерние таблицы `ad_campaigns` (объявления кампании
+и свой загруженный список телефонов клиента):
+
+```sql
+CREATE TABLE ad_campaign_ads (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE,
+ campaign_id BIGINT NOT NULL REFERENCES ad_campaigns ON DELETE CASCADE,
+ title VARCHAR(56) NOT NULL,
+ title2 VARCHAR(45), -- 30 + 15 узких символов
+ text VARCHAR(96) NOT NULL, -- 81 + 15 узких символов
+ href VARCHAR(1024) NOT NULL,
+ image_normal_hash VARCHAR(255),
+ image_wide_hash VARCHAR(255),
+ yandex_ad_id BIGINT,
+ moderation_status VARCHAR(16) NOT NULL DEFAULT 'draft', -- draft|MODERATION|ACCEPTED|REJECTED
+ moderation_reason VARCHAR(255),
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+);
+CREATE INDEX ON ad_campaign_ads (tenant_id, campaign_id);
+
+CREATE TABLE ad_campaign_phones (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE,
+ campaign_id BIGINT NOT NULL REFERENCES ad_campaigns ON DELETE CASCADE,
+ phone VARCHAR(11) NOT NULL, -- 79XXXXXXXXX
+ expires_at TIMESTAMP, -- период, на который клиент закинул номер (Р17)
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL
+);
+CREATE INDEX ON ad_campaign_phones (tenant_id, campaign_id);
+CREATE UNIQUE INDEX ON ad_campaign_phones (tenant_id, campaign_id, phone);
+```
+
+Миграции `app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php`
+и `app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php`,
+обе прогнаны на `liderra_testing` — DONE.
+
+**RLS**: та же идиома, что и у `ad_campaigns`/`ad_wallets` — `ENABLE`+`FORCE
+ROW LEVEL SECURITY`, политика `tenant_isolation` `USING (tenant_id =
+NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)` на
+обеих таблицах. **GRANT**: `ad_campaign_ads` — `crm_app_user` SELECT,
+INSERT, UPDATE (объявления не удаляются, только меняют
+`moderation_status`); `ad_campaign_phones` — `crm_app_user` SELECT, INSERT,
+UPDATE, **DELETE** (клиент вправе удалить свой загруженный номер). GRANT
+`crm_admin_user` на обе — отдельной миграцией в Task 5 плана (ещё не
+сделано).
+
+Тест `AdCampaignChildTablesMigrationTest` (RED→GREEN, TDD) — вставка одной
+строки объявления и одной строки телефона для тенанта+кампании через
+`DB::table(...)`, проверка счётчиков и обратного чтения полей. 1/1, 11
+assertions, зелёный. Полный `tests/Feature/Advertising` — 19/19, зелёные
+(регрессий нет). `composer stan` — 0 новых ошибок (только предсуществующие
+`SetTenantContext.php` и `SmscSmsProviderTest.php:52`).
+
+Структурно: +2 таблицы (`ad_campaign_ads`, `ad_campaign_phones`), +2
+RLS-политики (`tenant_isolation`), +2 обычных индекса (`tenant_id,
+campaign_id` на каждой) + 1 UNIQUE-индекс (`tenant_id, campaign_id, phone`
+на `ad_campaign_phones`). Функций/триггеров без изменений.
+План: `docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md` Task 4.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §4.
+
## v8.91 (2026-07-25) — Яндекс-канал, Часть B1, Task 3 — таблица ad_campaigns (кампании клиента, RLS)
Новая tenant-scoped таблица `ad_campaigns` (кампании клиента в Яндекс.Директе,
From 696638e2fc6b65daf206e4890e1071fa37ca1e85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 22:46:03 +0300
Subject: [PATCH 016/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20GRANT=20crm=5Fadmin=5Fuser=20=D0=BD=D0=B0=20ad?=
=?UTF-8?q?=5F*=20+=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=BA=D0=B0?=
=?UTF-8?q?=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Models/AdCampaign.php | 91 +++++++++++++++++++
app/app/Models/AdCampaignAd.php | 64 +++++++++++++
app/app/Models/AdCampaignPhone.php | 49 ++++++++++
...25_100300_grant_admin_read_advertising.php | 20 ++++
.../Advertising/AdCampaignModelTest.php | 22 +++++
db/CHANGELOG_schema.md | 45 +++++++++
6 files changed, 291 insertions(+)
create mode 100644 app/app/Models/AdCampaign.php
create mode 100644 app/app/Models/AdCampaignAd.php
create mode 100644 app/app/Models/AdCampaignPhone.php
create mode 100644 app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php
create mode 100644 app/tests/Feature/Advertising/AdCampaignModelTest.php
diff --git a/app/app/Models/AdCampaign.php b/app/app/Models/AdCampaign.php
new file mode 100644
index 00000000..7f18bb5b
--- /dev/null
+++ b/app/app/Models/AdCampaign.php
@@ -0,0 +1,91 @@
+ self::STATUS_DRAFT,
+ ];
+
+ protected $fillable = [
+ 'tenant_id',
+ 'channel',
+ 'name',
+ 'status',
+ 'audience_days',
+ 'use_uploaded_list',
+ 'weekly_budget_rub',
+ 'daily_budget_rub',
+ 'click_bid_rub',
+ 'yandex_segment_id',
+ 'yandex_retargeting_list_id',
+ 'yandex_campaign_id',
+ 'yandex_ad_group_id',
+ 'moderation_reason',
+ 'launched_at',
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'tenant_id' => 'integer',
+ 'audience_days' => 'integer',
+ 'use_uploaded_list' => 'boolean',
+ 'weekly_budget_rub' => 'decimal:2',
+ 'daily_budget_rub' => 'decimal:2',
+ 'click_bid_rub' => 'decimal:2',
+ 'yandex_segment_id' => 'integer',
+ 'yandex_retargeting_list_id' => 'integer',
+ 'yandex_campaign_id' => 'integer',
+ 'yandex_ad_group_id' => 'integer',
+ 'launched_at' => 'datetime',
+ ];
+ }
+
+ /** @return HasMany */
+ public function ads(): HasMany
+ {
+ return $this->hasMany(AdCampaignAd::class, 'campaign_id');
+ }
+
+ /** @return HasMany */
+ public function phones(): HasMany
+ {
+ return $this->hasMany(AdCampaignPhone::class, 'campaign_id');
+ }
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+}
diff --git a/app/app/Models/AdCampaignAd.php b/app/app/Models/AdCampaignAd.php
new file mode 100644
index 00000000..98f0bf3e
--- /dev/null
+++ b/app/app/Models/AdCampaignAd.php
@@ -0,0 +1,64 @@
+ 'integer',
+ 'campaign_id' => 'integer',
+ 'yandex_ad_id' => 'integer',
+ ];
+ }
+
+ /** @return BelongsTo */
+ public function campaign(): BelongsTo
+ {
+ return $this->belongsTo(AdCampaign::class, 'campaign_id');
+ }
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+}
diff --git a/app/app/Models/AdCampaignPhone.php b/app/app/Models/AdCampaignPhone.php
new file mode 100644
index 00000000..7c318d0e
--- /dev/null
+++ b/app/app/Models/AdCampaignPhone.php
@@ -0,0 +1,49 @@
+ 'integer',
+ 'campaign_id' => 'integer',
+ 'expires_at' => 'datetime',
+ ];
+ }
+
+ /** @return BelongsTo */
+ public function campaign(): BelongsTo
+ {
+ return $this->belongsTo(AdCampaign::class, 'campaign_id');
+ }
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+}
diff --git a/app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php b/app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php
new file mode 100644
index 00000000..20f726db
--- /dev/null
+++ b/app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php
@@ -0,0 +1,20 @@
+create();
+ $c = AdCampaign::create([
+ 'tenant_id' => $tenant->id, 'name' => 'Свежие клиенты', 'audience_days' => 10,
+ 'use_uploaded_list' => false, 'weekly_budget_rub' => '2500.00',
+ ]);
+ AdCampaignAd::create([
+ 'tenant_id' => $tenant->id, 'campaign_id' => $c->id,
+ 'title' => 'Заголовок', 'text' => 'Текст', 'href' => 'https://example.com',
+ ]);
+ expect($c->status)->toBe(AdCampaign::STATUS_DRAFT)
+ ->and($c->ads()->count())->toBe(1)
+ ->and($c->weekly_budget_rub)->toBe('2500.00');
+});
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 873f3a07..fad1d8a9 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,51 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.93 (2026-07-25) — Яндекс-канал, Часть B1, Task 5 — GRANT crm_admin_user на ad_* + модели Eloquent
+
+Закрывает совет B-adv-1 из RLS-ревью Части A: будущему админ-экрану
+расход/маржа нужен read-доступ к рекламным таблицам через
+`crm_admin_user` (BYPASSRLS), роль-канон — **`crm_admin_user`** (НЕ
+`crm_app_admin`, Р43). Никаких новых таблиц/колонок — только GRANT SELECT
+поверх уже существующих:
+
+```sql
+GRANT SELECT ON ad_wallets, ad_wallet_transactions, ad_wallet_holds, ad_settings TO crm_admin_user;
+GRANT SELECT ON ad_campaigns, ad_campaign_ads, ad_campaign_phones TO crm_admin_user;
+```
+
+Миграция `app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php`
+(`down()` — симметричный REVOKE), прогнана на `liderra_testing` — DONE.
+`ad_wallets`/`ad_wallet_transactions`/`ad_wallet_holds` (Часть A) и
+`ad_campaigns`/`ad_campaign_ads`/`ad_campaign_phones` (Task 3–4 этой части)
+получают SELECT; `ad_settings` уже имел `GRANT SELECT, UPDATE TO
+crm_admin_user` в своей миграции (2026_07_24_100300) — повторный GRANT
+SELECT идемпотентен, не ошибка.
+
+Той же задачей — три модели Eloquent: `App\Models\AdCampaign` (константы
+статусов `STATUS_DRAFT`…`STATUS_STOPPED_NO_FUNDS`, PHP-side default
+`status='draft'` через `$attributes` — паттерн `SalesProspect`/`ImportLog`,
+т.к. Eloquent не подтягивает DB-default обратно без `refresh()`; relations
+`ads()`/`phones()`/`tenant()`), `App\Models\AdCampaignAd` (константы
+модерации `MOD_DRAFT|MOD_MODERATION|MOD_ACCEPTED|MOD_REJECTED`, relations
+`campaign()`/`tenant()`), `App\Models\AdCampaignPhone` (relations
+`campaign()`/`tenant()`). Все — `declare(strict_types=1)`, `casts()`
+метод (не `$casts`-свойство), мирроят `AdWallet`/`AdWalletTransaction`
+Части A.
+
+Тест `AdCampaignModelTest` (RED→GREEN, TDD) — создаёт кампанию + одно
+объявление, проверяет `status === STATUS_DRAFT`, `ads()->count() === 1`,
+`weekly_budget_rub === '2500.00'`. 1/1, 3 assertions, зелёный. Полный
+`tests/Feature/Advertising` — 20/20, зелёные (регрессий нет). `composer
+stan` — 0 новых ошибок (только предсуществующие `SetTenantContext.php` и
+`SmscSmsProviderTest.php:52`).
+
+Структурно: 0 новых таблиц/колонок/индексов, +2 GRANT-выражения
+(множественные таблицы в одном statement). Функций/триггеров без
+изменений.
+План: `docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md` Task 5.
+Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §4.
+
## v8.92 (2026-07-25) — Яндекс-канал, Часть B1, Task 4 — таблицы ad_campaign_ads и ad_campaign_phones (RLS)
Две новые tenant-scoped дочерние таблицы `ad_campaigns` (объявления кампании
From a7c1036c58590bf0f5fc27fd0201d75f872d92ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 22:53:36 +0300
Subject: [PATCH 017/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20CampaignAudienceBuilder=20=E2=80=94=20=D1=81?=
=?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD?=
=?UTF-8?q?=D0=BE=D0=B2=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8?=
=?UTF-8?q?=20+=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Advertising/CampaignAudienceBuilder.php | 52 +++++++++++++++++++
.../CampaignAudienceBuilderTest.php | 50 ++++++++++++++++++
2 files changed, 102 insertions(+)
create mode 100644 app/app/Services/Advertising/CampaignAudienceBuilder.php
create mode 100644 app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php
diff --git a/app/app/Services/Advertising/CampaignAudienceBuilder.php b/app/app/Services/Advertising/CampaignAudienceBuilder.php
new file mode 100644
index 00000000..d69ce394
--- /dev/null
+++ b/app/app/Services/Advertising/CampaignAudienceBuilder.php
@@ -0,0 +1,52 @@
+ уникальные телефоны 79XXXXXXXXX для кампании. */
+ public function build(AdCampaign $campaign): array
+ {
+ return DB::transaction(function () use ($campaign): array {
+ // RLS-контекст (сделки tenant-scoped; сервис-контекст, не middleware).
+ DB::statement('SET LOCAL app.current_tenant_id = '.(int) $campaign->tenant_id);
+
+ // Явный where(tenant_id) — defense-in-depth: queue worker на prod
+ // (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует
+ // (00_create_roles.sql §5; тот же паттерн — HistoricalImportService::loadStatusOverrides).
+ // Без фильтра — cross-tenant утечка телефонов.
+ $dealPhones = DB::table('deals')
+ ->where('tenant_id', $campaign->tenant_id)
+ ->where('received_at', '>=', now()->subDays((int) $campaign->audience_days))
+ ->whereNull('deleted_at')
+ ->pluck('phone')
+ ->all();
+
+ $listPhones = [];
+ if ($campaign->use_uploaded_list) {
+ $listPhones = DB::table('ad_campaign_phones')
+ ->where('tenant_id', $campaign->tenant_id)
+ ->where('campaign_id', $campaign->id)
+ ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
+ ->pluck('phone')
+ ->all();
+ }
+
+ // deals.phone уже 79…; свой список приводим к формату Яндекса на всякий.
+ $normalized = PhoneNormalizer::cleanContactList([...$dealPhones, ...$listPhones]);
+
+ return array_values(array_unique($normalized));
+ });
+ }
+
+ public function size(AdCampaign $campaign): int
+ {
+ return count($this->build($campaign));
+ }
+}
diff --git a/app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php b/app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php
new file mode 100644
index 00000000..375af879
--- /dev/null
+++ b/app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php
@@ -0,0 +1,50 @@
+create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10,
+ 'use_uploaded_list' => true, 'weekly_budget_rub' => '2500.00',
+ ]);
+ // сделки в окне и вне окна (RLS-контекст выставит билдер)
+ Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'received_at' => now()->subDays(3)]);
+ Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'received_at' => now()->subDays(30)]);
+ DB::table('ad_campaign_phones')->insert([
+ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000003',
+ 'expires_at' => now()->addDays(5), 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ $phones = app(CampaignAudienceBuilder::class)->build($campaign);
+
+ expect($phones)->toContain('79990000001') // в окне
+ ->and($phones)->not->toContain('79990000002') // вне окна
+ ->and($phones)->toContain('79990000003'); // свой список
+});
+
+it('counts audience size', function () {
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id, 'name' => 'C2', 'audience_days' => 10,
+ 'use_uploaded_list' => true, 'weekly_budget_rub' => '2500.00',
+ ]);
+ Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000011', 'received_at' => now()->subDays(2)]);
+ Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000012', 'received_at' => now()->subDays(4)]);
+ // вне окна — не должна попасть в счётчик
+ Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000013', 'received_at' => now()->subDays(30)]);
+ DB::table('ad_campaign_phones')->insert([
+ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000014',
+ 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(),
+ ]);
+
+ $builder = app(CampaignAudienceBuilder::class);
+
+ expect($builder->size($campaign))->toBe(count($builder->build($campaign)))
+ ->and($builder->size($campaign))->toBe(3);
+});
From 8b2ad5492b154efe5d68274faa2a4d58c6ac27bd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:11:24 +0300
Subject: [PATCH 018/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20CreativeValidator=20=E2=80=94=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BE=D0=B1=D1=8A=D1=8F?=
=?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF=D0=BE=20=D0=BD?=
=?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D0=B2=D0=B0=D0=BC=20=D0=AF?=
=?UTF-8?q?=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Advertising/CreativeValidator.php | 173 ++++++++++++++++++
.../Advertising/CreativeValidatorTest.php | 81 ++++++++
2 files changed, 254 insertions(+)
create mode 100644 app/app/Services/Advertising/CreativeValidator.php
create mode 100644 app/tests/Unit/Advertising/CreativeValidatorTest.php
diff --git a/app/app/Services/Advertising/CreativeValidator.php b/app/app/Services/Advertising/CreativeValidator.php
new file mode 100644
index 00000000..9e3c8f5f
--- /dev/null
+++ b/app/app/Services/Advertising/CreativeValidator.php
@@ -0,0 +1,173 @@
+ ключ поля ('title'|'title2'|'text') → ошибка по-русски; пусто = ок
+ */
+ public function validateText(string $title, string $text, ?string $title2 = null): array
+ {
+ $errors = [];
+
+ $titleError = $this->validateTitle($title);
+ if ($titleError !== null) {
+ $errors['title'] = $titleError;
+ }
+
+ if ($title2 !== null) {
+ $title2Error = $this->validateTitle2($title2);
+ if ($title2Error !== null) {
+ $errors['title2'] = $title2Error;
+ }
+ }
+
+ $textError = $this->validateBodyText($text);
+ if ($textError !== null) {
+ $errors['text'] = $textError;
+ }
+
+ return $errors;
+ }
+
+ private function validateTitle(string $title): ?string
+ {
+ if (mb_strlen($title) > self::TITLE_MAX_LENGTH) {
+ return sprintf('Заголовок длиннее %d символов', self::TITLE_MAX_LENGTH);
+ }
+
+ foreach ($this->words($title) as $word) {
+ if (mb_strlen($word) > self::TITLE_MAX_WORD_LENGTH) {
+ return sprintf('В заголовке есть слово длиннее %d символов', self::TITLE_MAX_WORD_LENGTH);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Лимит title2: 30 обычных символов + до 15 «узких» сверху (узкие не входят в счёт обычных).
+ * Считаем отдельно узкие и не-узкие символы, а не общий счётчик — так граничные
+ * случаи (много узких, мало обычных) считаются правильно по духу норматива Яндекса.
+ */
+ private function validateTitle2(string $title2): ?string
+ {
+ [$normalCount, $narrowCount] = $this->countNormalAndNarrow($title2);
+
+ if ($normalCount > self::TITLE2_MAX_NORMAL || $narrowCount > self::TITLE2_MAX_NARROW) {
+ return sprintf(
+ 'Второй заголовок длиннее нормы (%d обычных символов + до %d узких)',
+ self::TITLE2_MAX_NORMAL,
+ self::TITLE2_MAX_NARROW
+ );
+ }
+
+ return null;
+ }
+
+ private function validateBodyText(string $text): ?string
+ {
+ [$normalCount, $narrowCount] = $this->countNormalAndNarrow($text);
+
+ if ($normalCount > self::TEXT_MAX_NORMAL || $narrowCount > self::TEXT_MAX_NARROW) {
+ return sprintf(
+ 'Текст длиннее нормы (%d обычных символов + до %d узких)',
+ self::TEXT_MAX_NORMAL,
+ self::TEXT_MAX_NARROW
+ );
+ }
+
+ foreach ($this->words($text) as $word) {
+ if (mb_strlen($word) > self::TEXT_MAX_WORD_LENGTH) {
+ return sprintf('В тексте есть слово длиннее %d символов', self::TEXT_MAX_WORD_LENGTH);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array{0: int, 1: int} [количество обычных символов, количество узких символов]
+ */
+ private function countNormalAndNarrow(string $value): array
+ {
+ $normalCount = 0;
+ $narrowCount = 0;
+
+ foreach (mb_str_split($value) as $char) {
+ if (in_array($char, self::NARROW_CHARS, true)) {
+ $narrowCount++;
+ } else {
+ $normalCount++;
+ }
+ }
+
+ return [$normalCount, $narrowCount];
+ }
+
+ /** @return list */
+ private function words(string $value): array
+ {
+ $parts = preg_split('/\s+/u', trim($value)) ?: [];
+
+ return array_values(array_filter($parts, static fn (string $w): bool => $w !== ''));
+ }
+
+ /**
+ * Проверка изображения-креатива (обычная картинка, не «широкая»).
+ *
+ * TODO(verify): точные пределы размера/пропорций/веса — сверить с AdImage
+ * object page Яндекса перед боем. Здесь — минимальный барьер 450×450,
+ * допустимые mime и вес ≤10 МБ; строгая пропорция сторон не проверяется.
+ *
+ * @return array ключ 'image' → ошибка по-русски; пусто = ок
+ */
+ public function validateImage(int $width, int $height, string $mime, int $bytes): array
+ {
+ if (! in_array($mime, self::IMAGE_ALLOWED_MIME, true)) {
+ return ['image' => sprintf('Недопустимый формат картинки: %s', $mime)];
+ }
+
+ if ($bytes > self::IMAGE_MAX_BYTES) {
+ return ['image' => sprintf('Картинка тяжелее %d МБ', (int) (self::IMAGE_MAX_BYTES / 1024 / 1024))];
+ }
+
+ if ($width < self::IMAGE_MIN_SIDE || $height < self::IMAGE_MIN_SIDE) {
+ return ['image' => sprintf('Картинка меньше минимума %d×%d px', self::IMAGE_MIN_SIDE, self::IMAGE_MIN_SIDE)];
+ }
+
+ return [];
+ }
+}
diff --git a/app/tests/Unit/Advertising/CreativeValidatorTest.php b/app/tests/Unit/Advertising/CreativeValidatorTest.php
new file mode 100644
index 00000000..44c2d711
--- /dev/null
+++ b/app/tests/Unit/Advertising/CreativeValidatorTest.php
@@ -0,0 +1,81 @@
+validateText(str_repeat('а', 57), 'ok text'))->toHaveKey('title'); // >56 → ошибка
+ expect($v->validateText('Норм заголовок', str_repeat('б', 82)))->toHaveKey('text'); // >81
+ expect($v->validateText('Норм заголовок', 'Короткий текст'))->toBe([]); // ок
+});
+
+it('rejects title with a single word longer than 22 characters', function () {
+ $v = new CreativeValidator;
+
+ $longWord = str_repeat('ё', 23);
+ expect($v->validateText($longWord, 'Короткий текст'))->toHaveKey('title');
+
+ $okWord = str_repeat('ё', 22);
+ expect($v->validateText($okWord, 'Короткий текст'))->toBe([]);
+});
+
+it('rejects text with a single word longer than 23 characters', function () {
+ $v = new CreativeValidator;
+
+ $longWord = str_repeat('ж', 24);
+ expect($v->validateText('Норм заголовок', $longWord))->toHaveKey('text');
+
+ $okWord = str_repeat('ж', 23);
+ expect($v->validateText('Норм заголовок', $okWord))->toBe([]);
+});
+
+it('accepts title2 within limit (30 normal + up to 15 narrow) and rejects too-long', function () {
+ $v = new CreativeValidator;
+
+ // 30 обычных символов + 15 узких (кавычки) — на грани, должно быть ок
+ $okTitle2 = str_repeat('а', 30).str_repeat('"', 15);
+ expect($v->validateText('Норм заголовок', 'Короткий текст', $okTitle2))->toBe([]);
+
+ // 31 обычный символ сверх лимита → ошибка
+ $tooLongTitle2 = str_repeat('а', 31);
+ expect($v->validateText('Норм заголовок', 'Короткий текст', $tooLongTitle2))->toHaveKey('title2');
+
+ // узких больше 15 → ошибка
+ $tooManyNarrow = str_repeat('а', 20).str_repeat('"', 16);
+ expect($v->validateText('Норм заголовок', 'Короткий текст', $tooManyNarrow))->toHaveKey('title2');
+});
+
+it('accepts text within limit (81 normal + up to 15 narrow) and rejects too-long', function () {
+ $v = new CreativeValidator;
+
+ // 4 слова (23+23+23+8=77 + 3 пробела между ними = 80) + 1 разделительный пробел (81 обычный)
+ // + отдельное «слово» из 15 узких символов (не считается в лимит обычных).
+ $okText = implode(' ', [str_repeat('б', 23), str_repeat('б', 23), str_repeat('б', 23), str_repeat('б', 8)])
+ .' '.str_repeat('!', 15);
+ expect($v->validateText('Норм заголовок', $okText))->toBe([]);
+
+ $tooLongText = str_repeat('б', 82).str_repeat('!', 15);
+ expect($v->validateText('Норм заголовок', $tooLongText))->toHaveKey('text');
+});
+
+it('validates image dimensions, weight and mime', function () {
+ $v = new CreativeValidator;
+
+ // корректная обычная картинка
+ expect($v->validateImage(600, 600, 'image/jpeg', 500_000))->toBe([]);
+
+ // слишком маленькая
+ expect($v->validateImage(200, 200, 'image/jpeg', 500_000))->toHaveKey('image');
+
+ // слишком тяжёлая (>10 МБ)
+ expect($v->validateImage(600, 600, 'image/jpeg', 11 * 1024 * 1024))->toHaveKey('image');
+
+ // неверный mime
+ expect($v->validateImage(600, 600, 'image/webp', 500_000))->toHaveKey('image');
+});
From ca42e3b3aa2e6217fa269be811af7be3de95fd0f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:19:23 +0300
Subject: [PATCH 019/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20CampaignLauncher=20=E2=80=94=20=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BF=D1=83=D1=81=D0=BA=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B8=20(=D1=81=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82?=
=?UTF-8?q?=E2=86=92=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=E2=86=92=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BC=D0=BE=D1=80=D0=BE=D0=B7=D0=BA=D0=B0)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Advertising/AudienceTooSmallException.php | 15 ++
.../Services/Advertising/CampaignLauncher.php | 87 +++++++++
.../Advertising/CampaignLauncherTest.php | 167 ++++++++++++++++++
3 files changed, 269 insertions(+)
create mode 100644 app/app/Exceptions/Advertising/AudienceTooSmallException.php
create mode 100644 app/app/Services/Advertising/CampaignLauncher.php
create mode 100644 app/tests/Feature/Advertising/CampaignLauncherTest.php
diff --git a/app/app/Exceptions/Advertising/AudienceTooSmallException.php b/app/app/Exceptions/Advertising/AudienceTooSmallException.php
new file mode 100644
index 00000000..8c7aa859
--- /dev/null
+++ b/app/app/Exceptions/Advertising/AudienceTooSmallException.php
@@ -0,0 +1,15 @@
+audience->build($campaign);
+ if (count($phones) < 100) {
+ throw new AudienceTooSmallException(count($phones)); // Яндекс не запустит сегмент <100
+ }
+
+ $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00'));
+
+ // 1) Сегмент Аудиторий.
+ $audienceClient = new YandexAudienceClient((string) config('services.yandex_audience.token'));
+ $segmentId = (int) $campaign->yandex_segment_id
+ ?: $audienceClient->createSegment('Лидерра кампания #'.$campaign->id, $phones);
+
+ // 2) Директ: retargeting → campaign → adgroup → audience target → ads.
+ $direct = new YandexDirectClient(
+ (string) config('services.yandex_direct.base_url'),
+ (string) config('services.yandex_direct.token'),
+ );
+ $retId = $direct->addRetargetingList('Лидерра #'.$campaign->id, $segmentId);
+
+ // Недельный бюджет в Директ: клиентский ÷ наценку → микросы.
+ $weeklyYandexRub = $markup->yandexFromClient((string) $campaign->weekly_budget_rub);
+ $weeklyMicros = (int) bcmul($weeklyYandexRub, '1000000', 0);
+ $campaignId = $direct->addCampaign('Лидерра #'.$campaign->id, now()->toDateString(), $weeklyMicros);
+ $adGroupId = $direct->addAdGroup($campaignId, 'Группа #'.$campaign->id, config('services.yandex_direct.region_ids'));
+
+ $bidMicros = (int) bcmul($markup->yandexFromClient((string) ($campaign->click_bid_rub ?? '10.00')), '1000000', 0);
+ $direct->addAudienceTarget($adGroupId, $retId, $bidMicros);
+
+ foreach ($campaign->ads as $ad) {
+ $textAd = ['Title' => $ad->title, 'Text' => $ad->text, 'Href' => $ad->href];
+ if ($ad->title2) {
+ $textAd['Title2'] = $ad->title2;
+ }
+ if ($ad->image_normal_hash) {
+ $textAd['AdImageHash'] = $ad->image_normal_hash;
+ }
+ $yandexAdId = $direct->addTextAd($adGroupId, $textAd);
+ $ad->update(['yandex_ad_id' => $yandexAdId, 'moderation_status' => 'MODERATION']);
+ }
+
+ // 3) Заморозить недельный бюджет (клиентские ₽) в кошельке (Часть A).
+ $this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, (string) $campaign->weekly_budget_rub);
+
+ // 4) Записать id и статус.
+ $campaign->update([
+ 'yandex_segment_id' => $segmentId,
+ 'yandex_retargeting_list_id' => $retId,
+ 'yandex_campaign_id' => $campaignId,
+ 'yandex_ad_group_id' => $adGroupId,
+ 'status' => AdCampaign::STATUS_PENDING_MODERATION,
+ 'launched_at' => now(),
+ ]);
+ }
+}
diff --git a/app/tests/Feature/Advertising/CampaignLauncherTest.php b/app/tests/Feature/Advertising/CampaignLauncherTest.php
new file mode 100644
index 00000000..43886309
--- /dev/null
+++ b/app/tests/Feature/Advertising/CampaignLauncherTest.php
@@ -0,0 +1,167 @@
+ Http::response(['segment' => ['id' => 900001]]),
+ '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]),
+ '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]),
+ '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]),
+ '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]),
+ '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]),
+ '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
+ ]);
+}
+
+function configureYandex(): void
+{
+ config(['services.yandex_direct.enabled' => true]);
+ config(['services.yandex_direct.token' => 'DIRTOKEN']);
+ config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
+ config(['services.yandex_audience.token' => 'AUDTOKEN']);
+}
+
+/** Наполняет ad_campaign_phones $count уникальными номерами для кампании (обходит фабрику Deal — быстрее). */
+function seedAudience(AdCampaign $campaign, int $count): void
+{
+ $rows = [];
+ for ($i = 0; $i < $count; $i++) {
+ $rows[] = [
+ 'tenant_id' => $campaign->tenant_id,
+ 'campaign_id' => $campaign->id,
+ 'phone' => sprintf('799900%05d', $i),
+ 'expires_at' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ];
+ }
+ DB::table('ad_campaign_phones')->insert($rows);
+}
+
+it('launches a campaign: segment → Direct → freeze budget → pending moderation', function () {
+ configureYandex();
+ fakeYandexEndpoints();
+
+ $tenant = Tenant::factory()->create();
+ app(AdWalletService::class)->topup($tenant->id, '5000.00', 'yandex', 'тест');
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'C',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ 'weekly_budget_rub' => '2600.00',
+ 'click_bid_rub' => '13.00',
+ ]);
+ $ad = AdCampaignAd::create([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'title' => 'Заголовок',
+ 'text' => 'Текст объявления',
+ 'href' => 'https://liderra.ru',
+ 'moderation_status' => 'draft',
+ ]);
+ seedAudience($campaign, 100);
+
+ app(CampaignLauncher::class)->launch($campaign);
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION)
+ ->and($campaign->yandex_segment_id)->toBe(900001)
+ ->and($campaign->yandex_retargeting_list_id)->toBe(111)
+ ->and($campaign->yandex_campaign_id)->toBe(222)
+ ->and($campaign->yandex_ad_group_id)->toBe(333)
+ ->and($campaign->launched_at)->not->toBeNull();
+
+ $ad->refresh();
+ expect($ad->yandex_ad_id)->toBe(555)
+ ->and($ad->moderation_status)->toBe('MODERATION');
+
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->frozen_rub)->toBe('2600.00');
+
+ $hold = AdWalletHold::where('tenant_id', $tenant->id)
+ ->where('channel', 'yandex')->where('source_type', 'campaign')
+ ->where('source_id', $campaign->id)->where('status', AdWalletHold::STATUS_ACTIVE)->first();
+ expect($hold)->not->toBeNull()
+ ->and($hold->amount_rub)->toBe('2600.00');
+
+ Http::assertSent(function ($request) {
+ if (! str_contains($request->url(), '/json/v5/campaigns')) {
+ return false;
+ }
+
+ $limit = $request['params']['Campaigns'][0]['TextCampaign']['BiddingStrategy']['Network']['NetworkHighestPosition']['WeeklySpendLimit'] ?? null;
+
+ return $limit === 2000000000;
+ });
+});
+
+it('throws AudienceTooSmallException and does not freeze when audience is under 100', function () {
+ configureYandex();
+ fakeYandexEndpoints();
+
+ $tenant = Tenant::factory()->create();
+ app(AdWalletService::class)->topup($tenant->id, '5000.00', 'yandex', 'тест');
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'C',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ 'weekly_budget_rub' => '2600.00',
+ 'click_bid_rub' => '13.00',
+ ]);
+ seedAudience($campaign, 50);
+
+ expect(fn () => app(CampaignLauncher::class)->launch($campaign))
+ ->toThrow(AudienceTooSmallException::class);
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
+
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->frozen_rub)->toBe('0.00');
+
+ $holdExists = AdWalletHold::where('tenant_id', $tenant->id)
+ ->where('source_type', 'campaign')->where('source_id', $campaign->id)
+ ->where('status', AdWalletHold::STATUS_ACTIVE)->exists();
+ expect($holdExists)->toBeFalse();
+});
+
+it('throws RuntimeException when yandex_direct is disabled and creates nothing', function () {
+ config(['services.yandex_direct.enabled' => false]);
+ fakeYandexEndpoints();
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'C',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => false,
+ 'weekly_budget_rub' => '2600.00',
+ 'click_bid_rub' => '13.00',
+ ]);
+
+ expect(fn () => app(CampaignLauncher::class)->launch($campaign))
+ ->toThrow(RuntimeException::class);
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT)
+ ->and($campaign->yandex_segment_id)->toBeNull();
+
+ Http::assertNothingSent();
+});
From 620cc2eeb1eedde52095f76a52747f8794ba0de2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:27:50 +0300
Subject: [PATCH 020/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20ChargeCampaignSpendJob=20=E2=80=94=20=D1=81?=
=?UTF-8?q?=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BF=D0=B8?=
=?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D1=81=D1=85=D0=BE?=
=?UTF-8?q?=D0=B4=D0=B0=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20(Co?=
=?UTF-8?q?st=C3=971.3)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Jobs/ChargeCampaignSpendJob.php | 104 ++++++++++++++++
.../Advertising/YandexDirectClient.php | 47 ++++++++
.../ChargeCampaignSpendJobTest.php | 112 ++++++++++++++++++
3 files changed, 263 insertions(+)
create mode 100644 app/app/Jobs/ChargeCampaignSpendJob.php
create mode 100644 app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php
new file mode 100644
index 00000000..67a776a0
--- /dev/null
+++ b/app/app/Jobs/ChargeCampaignSpendJob.php
@@ -0,0 +1,104 @@
+id}:{date}" (дата — вчерашние сутки, которые списываем).
+ */
+class ChargeCampaignSpendJob implements ShouldQueue
+{
+ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+ public function handle(): void
+ {
+ if (! config('services.yandex_direct.enabled')) {
+ return;
+ }
+
+ $wallet = app(AdWalletService::class);
+ $gate = app(AdWalletGate::class);
+ $stopAll = app(AdStopAllService::class);
+
+ $date = Carbon::now()->subDay()->toDateString();
+ $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00'));
+ $direct = new YandexDirectClient(
+ $this->configString('services.yandex_direct.base_url'),
+ $this->configString('services.yandex_direct.token'),
+ );
+
+ $campaigns = AdCampaign::where('status', AdCampaign::STATUS_RUNNING)
+ ->whereNotNull('yandex_campaign_id')
+ ->get();
+
+ foreach ($campaigns as $campaign) {
+ try {
+ $spend = $direct->getCampaignSpend((int) $campaign->yandex_campaign_id, $date);
+ $clientCost = $markup->clientFromYandex($spend['cost']);
+
+ if (bccomp($clientCost, '0', 2) === 0) {
+ continue;
+ }
+
+ $tenantId = (int) $campaign->tenant_id;
+
+ $wallet->charge(
+ $tenantId,
+ 'yandex',
+ 'campaign',
+ (int) $campaign->id,
+ $clientCost,
+ "yandex:{$campaign->id}:{$date}",
+ );
+
+ if (! $gate->isSolvent($tenantId)) {
+ $stopAll->stopAll($tenantId);
+ }
+ } catch (Throwable $e) {
+ Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [
+ 'campaign_id' => $campaign->id,
+ 'tenant_id' => $campaign->tenant_id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
+ private function configString(string $key, string $default = ''): string
+ {
+ $value = config($key, $default);
+
+ return is_string($value) ? $value : $default;
+ }
+}
diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php
index ba72521d..ed4a3955 100644
--- a/app/app/Services/Advertising/YandexDirectClient.php
+++ b/app/app/Services/Advertising/YandexDirectClient.php
@@ -149,6 +149,53 @@ final class YandexDirectClient
$this->call('v5/campaigns', 'resume', ['SelectionCriteria' => ['Ids' => [$campaignId]]]);
}
+ /**
+ * Reports CAMPAIGN_PERFORMANCE_REPORT за дату. Возвращает ['clicks'=>int,'cost'=>string ₽].
+ *
+ * ⚠️ TODO(verify): Reports часто отдаёт TSV (не JSON) и может строиться асинхронно
+ * (HTTP 201/202 + заголовок retryIn) — перед боем сверить формат/async/НДС по
+ * yandex.ru/dev/direct/doc/ru/reports/* и при необходимости добавить ретрай-поллинг.
+ * Здесь — минимальный синхронный путь (для песочницы/тестов).
+ *
+ * @return array{clicks: int, cost: string}
+ */
+ public function getCampaignSpend(int $campaignId, string $date): array
+ {
+ $resp = Http::withToken($this->token)
+ ->withHeaders([
+ 'returnMoneyInMicros' => 'false',
+ 'skipReportHeader' => 'true',
+ 'skipReportSummaryRow' => 'true',
+ 'skipColumnHeader' => 'true',
+ ])
+ ->acceptJson()
+ ->post(rtrim($this->baseUrl, '/').'/json/v5/reports', ['params' => [
+ 'SelectionCriteria' => [
+ 'DateFrom' => $date,
+ 'DateTo' => $date,
+ 'Filter' => [[
+ 'Field' => 'CampaignId',
+ 'Operator' => 'EQUALS',
+ 'Values' => [(string) $campaignId],
+ ]],
+ ],
+ 'FieldNames' => ['Clicks', 'Cost'],
+ 'ReportName' => 'spend-'.$campaignId.'-'.$date,
+ 'ReportType' => 'CAMPAIGN_PERFORMANCE_REPORT',
+ 'DateRangeType' => 'CUSTOM_DATE',
+ 'Format' => 'TSV',
+ 'IncludeVAT' => 'YES', // ⚠️ verify: влияет на Cost
+ ]]);
+
+ $body = trim((string) $resp->body());
+ if ($body === '') {
+ return ['clicks' => 0, 'cost' => '0.00'];
+ }
+ [$clicks, $cost] = array_pad(explode("\t", $body), 2, '0');
+
+ return ['clicks' => (int) trim($clicks), 'cost' => bcadd(trim($cost) !== '' ? trim($cost) : '0', '0', 2)];
+ }
+
/**
* Общий вызов JSON API. Бросает RuntimeException при error или не-2xx.
*
diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
new file mode 100644
index 00000000..2d4fd2b7
--- /dev/null
+++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
@@ -0,0 +1,112 @@
+ true]);
+ config(['services.yandex_direct.token' => 'DIRTOKEN']);
+ config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
+}
+
+/** TSV: Clicks=7, Cost=200.00 ₽ (яндексовы рубли, до наценки). */
+function fakeYandexReportsSpend(string $tsv = "7\t200.00"): void
+{
+ Http::fake(['*/json/v5/reports' => Http::response($tsv, 200)]);
+}
+
+function makeRunningCampaign(int $tenantId): AdCampaign
+{
+ return AdCampaign::create([
+ 'tenant_id' => $tenantId,
+ 'name' => 'Кампания списания',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_campaign_id' => 222,
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '500.00',
+ ]);
+}
+
+it('charges the client the marked-up cost of yesterday\'s Direct spend', function () {
+ configureYandexDirectForSpend();
+ fakeYandexReportsSpend();
+
+ $tenant = Tenant::factory()->create();
+ app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
+ $campaign = makeRunningCampaign($tenant->id);
+
+ app(ChargeCampaignSpendJob::class)->handle();
+
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->balance_rub)->toBe('740.00'); // 1000.00 − 260.00
+
+ $date = Carbon::now()->subDay()->toDateString();
+ $tx = AdWalletTransaction::where('tenant_id', $tenant->id)
+ ->where('external_key', "yandex:{$campaign->id}:{$date}")
+ ->first();
+
+ expect($tx)->not->toBeNull()
+ ->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE)
+ ->and($tx->amount_rub)->toBe('-260.00');
+});
+
+it('does not double-charge on a second run (idempotent by external_key)', function () {
+ configureYandexDirectForSpend();
+ fakeYandexReportsSpend();
+
+ $tenant = Tenant::factory()->create();
+ app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
+ makeRunningCampaign($tenant->id);
+
+ app(ChargeCampaignSpendJob::class)->handle();
+ $balanceAfterFirst = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
+
+ app(ChargeCampaignSpendJob::class)->handle();
+ $balanceAfterSecond = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
+
+ expect($balanceAfterSecond)->toBe($balanceAfterFirst)
+ ->and($balanceAfterSecond)->toBe('740.00');
+});
+
+it('stops all advertising when the charge leaves the tenant insolvent', function () {
+ Event::fake([AdvertisingStopped::class]);
+ configureYandexDirectForSpend();
+ fakeYandexReportsSpend();
+
+ $tenant = Tenant::factory()->create();
+ // Малый баланс, крупная заморозка → после списания 260.00 баланс уйдёт ниже frozen.
+ app(AdWalletService::class)->topup($tenant->id, '300.00', 'yandex', 'тест');
+ $campaign = makeRunningCampaign($tenant->id);
+ app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '200.00');
+
+ app(ChargeCampaignSpendJob::class)->handle();
+
+ Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id);
+});
+
+it('does nothing and sends no HTTP when the yandex_direct switch is off', function () {
+ config(['services.yandex_direct.enabled' => false]);
+ fakeYandexReportsSpend();
+
+ $tenant = Tenant::factory()->create();
+ app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');
+ makeRunningCampaign($tenant->id);
+
+ app(ChargeCampaignSpendJob::class)->handle();
+
+ Http::assertNothingSent();
+
+ $wallet = AdWallet::where('tenant_id', $tenant->id)->first();
+ expect($wallet->balance_rub)->toBe('1000.00');
+});
From 0381d080216500864dfe95f4b17d86acc935e446 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:30:09 +0300
Subject: [PATCH 021/147] =?UTF-8?q?chore(cspell):=20=D1=81=D0=BB=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B0=20=D0=B4=D0=BB=D1=8F=20ChargeCampaignSpendJob=20(?=
=?UTF-8?q?=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0,=20=D1=8F=D0=BD?=
=?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=D0=BE=D0=B2=D1=8B,=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
cspell-words.txt | 3 +++
1 file changed, 3 insertions(+)
diff --git a/cspell-words.txt b/cspell-words.txt
index 5c1ead87..7f80f94c 100644
--- a/cspell-words.txt
+++ b/cspell-words.txt
@@ -2437,3 +2437,6 @@ chast
dlya
klientov
kanal
+Директа
+яндексовы
+поллинг
From 7f4aca9cf1dd4921a3799a1a0d38bb67fe248cbf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:36:26 +0300
Subject: [PATCH 022/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20SyncCampaignAudienceJob=20=E2=80=94=20=D0=BD?=
=?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B0=D1=8F=20replace-=D0=B7=D0=B0=D0=BB?=
=?UTF-8?q?=D0=B8=D0=B2=D0=BA=D0=B0=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=BE?=
=?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8?=
=?UTF-8?q?=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Jobs/SyncCampaignAudienceJob.php | 87 ++++++++++++
.../SyncCampaignAudienceJobTest.php | 134 ++++++++++++++++++
2 files changed, 221 insertions(+)
create mode 100644 app/app/Jobs/SyncCampaignAudienceJob.php
create mode 100644 app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
diff --git a/app/app/Jobs/SyncCampaignAudienceJob.php b/app/app/Jobs/SyncCampaignAudienceJob.php
new file mode 100644
index 00000000..5ee62092
--- /dev/null
+++ b/app/app/Jobs/SyncCampaignAudienceJob.php
@@ -0,0 +1,87 @@
+handle() в тестах
+ * (см. разбор в задаче 9: параметры handle() не резолвятся при прямом вызове).
+ */
+class SyncCampaignAudienceJob implements ShouldQueue
+{
+ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+ public function handle(): void
+ {
+ if (! config('services.yandex_direct.enabled')) {
+ return;
+ }
+
+ $token = $this->configString('services.yandex_audience.token');
+
+ if ($token === '') {
+ return;
+ }
+
+ $builder = app(CampaignAudienceBuilder::class);
+ $client = new YandexAudienceClient($token);
+
+ $campaigns = AdCampaign::whereIn('status', [
+ AdCampaign::STATUS_RUNNING,
+ AdCampaign::STATUS_PENDING_MODERATION,
+ ])->whereNotNull('yandex_segment_id')->get();
+
+ foreach ($campaigns as $campaign) {
+ $phones = $builder->build($campaign);
+
+ if ($phones === []) {
+ // Пустая замена — Яндекс её отвергает; тихо пропускаем эту кампанию.
+ continue;
+ }
+
+ try {
+ $client->modifyData((int) $campaign->yandex_segment_id, $phones, 'replace');
+ } catch (Throwable $e) {
+ // Сбой одной кампании не должен валить всю ночную заливку остальных.
+ // Телефоны (ПДн) в текст лога не попадают — только id кампании.
+ Log::warning('SyncCampaignAudienceJob: '.$e->getMessage(), ['campaign' => $campaign->id]);
+ }
+ }
+ }
+
+ /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
+ private function configString(string $key, string $default = ''): string
+ {
+ $value = config($key, $default);
+
+ return is_string($value) ? $value : $default;
+ }
+}
diff --git a/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php b/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
new file mode 100644
index 00000000..8c2f2103
--- /dev/null
+++ b/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
@@ -0,0 +1,134 @@
+ true,
+ 'services.yandex_audience.token' => 'AUDTOKEN',
+ ]);
+ Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания ночной заливки',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_segment_id' => 900001,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+ DB::table('ad_campaign_phones')->insert([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'phone' => '79990000001',
+ 'expires_at' => now()->addDays(5),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ app(SyncCampaignAudienceJob::class)->handle();
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), 'modify_data')
+ && str_contains($request->url(), 'replace');
+ });
+});
+
+it('рубильник выключен — ни одного обращения к Яндексу', function () {
+ config([
+ 'services.yandex_direct.enabled' => false,
+ 'services.yandex_audience.token' => 'AUDTOKEN',
+ ]);
+ Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания при выключенном рубильнике',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_segment_id' => 900002,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+ DB::table('ad_campaign_phones')->insert([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'phone' => '79990000002',
+ 'expires_at' => now()->addDays(5),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ app(SyncCampaignAudienceJob::class)->handle();
+
+ Http::assertNothingSent();
+});
+
+it('пустая аудитория кампании — пропускает без обращения к Яндексу', function () {
+ config([
+ 'services.yandex_direct.enabled' => true,
+ 'services.yandex_audience.token' => 'AUDTOKEN',
+ ]);
+ Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
+
+ $tenant = Tenant::factory()->create();
+ AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания без аудитории',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_segment_id' => 900003,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => false,
+ ]);
+
+ app(SyncCampaignAudienceJob::class)->handle();
+
+ Http::assertNothingSent();
+});
+
+it('кампания без сегмента Яндекса — пропускает', function () {
+ config([
+ 'services.yandex_direct.enabled' => true,
+ 'services.yandex_audience.token' => 'AUDTOKEN',
+ ]);
+ Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания без сегмента',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+
+ DB::table('ad_campaign_phones')->insert([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'phone' => '79990000004',
+ 'expires_at' => now()->addDays(5),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ app(SyncCampaignAudienceJob::class)->handle();
+
+ Http::assertNothingSent();
+});
From 7f150922be8c7bc482f1104fd475c082e7a67ef4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:44:21 +0300
Subject: [PATCH 023/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BC=D0=BE=D0=B4=D0=B5=D1=80=D0=B0=D1=86?=
=?UTF-8?q?=D0=B8=D1=8F=20(passthrough)=20+=20=D0=BF=D0=B0=D1=83=D0=B7?=
=?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9=20?=
=?UTF-8?q?=D0=BF=D0=BE=20AdvertisingStopped?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/app/Jobs/SyncCampaignModerationJob.php | 118 ++++++++++++
app/app/Listeners/PauseCampaignsOnAdStop.php | 43 +++++
.../AdvertisingEventServiceProvider.php | 18 ++
app/bootstrap/providers.php | 2 +
.../CampaignModerationAndStopTest.php | 172 ++++++++++++++++++
5 files changed, 353 insertions(+)
create mode 100644 app/app/Jobs/SyncCampaignModerationJob.php
create mode 100644 app/app/Listeners/PauseCampaignsOnAdStop.php
create mode 100644 app/app/Providers/AdvertisingEventServiceProvider.php
create mode 100644 app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php
new file mode 100644
index 00000000..84bbed3b
--- /dev/null
+++ b/app/app/Jobs/SyncCampaignModerationJob.php
@@ -0,0 +1,118 @@
+configString('services.yandex_direct.base_url'),
+ $this->configString('services.yandex_direct.token'),
+ );
+
+ $campaigns = AdCampaign::whereIn('status', [
+ AdCampaign::STATUS_RUNNING,
+ AdCampaign::STATUS_PENDING_MODERATION,
+ ])->whereNotNull('yandex_campaign_id')->with('ads')->get();
+
+ foreach ($campaigns as $campaign) {
+ $ads = $campaign->ads->filter(fn ($ad) => $ad->yandex_ad_id !== null);
+
+ if ($ads->isEmpty()) {
+ continue;
+ }
+
+ try {
+ $moderation = $direct->getAdsModeration($ads->pluck('yandex_ad_id')->all());
+ } catch (Throwable $e) {
+ // Сбой одной кампании не должен валить весь опрос остальных.
+ // ПДн в лог не попадают — только id кампании.
+ Log::warning('SyncCampaignModerationJob: '.$e->getMessage(), ['campaign' => $campaign->id]);
+
+ continue;
+ }
+
+ $hasRejected = false;
+ $rejectedReason = null;
+ $allAccepted = true;
+
+ foreach ($ads as $ad) {
+ $info = $moderation[(int) $ad->yandex_ad_id] ?? null;
+ if ($info === null) {
+ $allAccepted = false;
+
+ continue;
+ }
+
+ $status = $info['status'] ?? null;
+ $reason = $info['reason'] ?? null;
+
+ $ad->update([
+ 'moderation_status' => $status,
+ 'moderation_reason' => $reason,
+ ]);
+
+ if ($status === 'REJECTED') {
+ if (! $hasRejected) {
+ $hasRejected = true;
+ $rejectedReason = $reason;
+ }
+ } elseif ($status !== 'ACCEPTED') {
+ $allAccepted = false;
+ }
+ }
+
+ $newStatus = $hasRejected
+ ? AdCampaign::STATUS_REJECTED
+ : ($allAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_PENDING_MODERATION);
+
+ if ($newStatus !== $campaign->status) {
+ $campaign->update([
+ 'status' => $newStatus,
+ 'moderation_reason' => $hasRejected ? $rejectedReason : $campaign->moderation_reason,
+ ]);
+ }
+ }
+ }
+
+ /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
+ private function configString(string $key, string $default = ''): string
+ {
+ $value = config($key, $default);
+
+ return is_string($value) ? $value : $default;
+ }
+}
diff --git a/app/app/Listeners/PauseCampaignsOnAdStop.php b/app/app/Listeners/PauseCampaignsOnAdStop.php
new file mode 100644
index 00000000..d6793d34
--- /dev/null
+++ b/app/app/Listeners/PauseCampaignsOnAdStop.php
@@ -0,0 +1,43 @@
+where('tenant_id', $event->tenantId)
+ ->whereIn('status', [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION])
+ ->get();
+
+ if ($campaigns->isEmpty()) {
+ return;
+ }
+
+ $enabled = (bool) config('services.yandex_direct.enabled');
+ $direct = $enabled
+ ? new YandexDirectClient((string) config('services.yandex_direct.base_url'), (string) config('services.yandex_direct.token'))
+ : null;
+
+ foreach ($campaigns as $campaign) {
+ if ($direct !== null && $campaign->yandex_campaign_id !== null) {
+ try {
+ $direct->suspendCampaign((int) $campaign->yandex_campaign_id);
+ } catch (Throwable $e) {
+ Log::warning('PauseCampaignsOnAdStop suspend failed: '.$e->getMessage(), ['campaign' => $campaign->id]);
+ }
+ }
+ $campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]);
+ }
+ }
+}
diff --git a/app/app/Providers/AdvertisingEventServiceProvider.php b/app/app/Providers/AdvertisingEventServiceProvider.php
new file mode 100644
index 00000000..07b3ec5f
--- /dev/null
+++ b/app/app/Providers/AdvertisingEventServiceProvider.php
@@ -0,0 +1,18 @@
+ true,
+ 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
+ 'services.yandex_direct.token' => 'DIRTOKEN',
+ ]);
+ Http::fake([
+ '*/json/v5/ads' => Http::response(['result' => ['Ads' => [
+ ['Id' => 555, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Нет гарантий'],
+ ]]], 200),
+ ]);
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания на модерации',
+ 'status' => AdCampaign::STATUS_PENDING_MODERATION,
+ 'yandex_campaign_id' => 222,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+ $ad = AdCampaignAd::create([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'title' => 'Заголовок',
+ 'text' => 'Текст объявления',
+ 'href' => 'https://example.test',
+ 'yandex_ad_id' => 555,
+ 'moderation_status' => 'MODERATION',
+ ]);
+
+ app(SyncCampaignModerationJob::class)->handle();
+
+ $ad->refresh();
+ expect($ad->moderation_status)->toBe('REJECTED');
+ expect($ad->moderation_reason)->toBe('Нет гарантий');
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED);
+});
+
+it('модерация: все ACCEPTED переводит кампанию в running (passthrough)', function () {
+ config([
+ 'services.yandex_direct.enabled' => true,
+ 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
+ 'services.yandex_direct.token' => 'DIRTOKEN',
+ ]);
+ Http::fake([
+ '*/json/v5/ads' => Http::response(['result' => ['Ads' => [
+ ['Id' => 555, 'Status' => 'ACCEPTED', 'State' => 'ON', 'StatusClarification' => null],
+ ]]], 200),
+ ]);
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания на модерации 2',
+ 'status' => AdCampaign::STATUS_PENDING_MODERATION,
+ 'yandex_campaign_id' => 223,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+ $ad = AdCampaignAd::create([
+ 'tenant_id' => $tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'title' => 'Заголовок',
+ 'text' => 'Текст объявления',
+ 'href' => 'https://example.test',
+ 'yandex_ad_id' => 555,
+ 'moderation_status' => 'MODERATION',
+ ]);
+
+ app(SyncCampaignModerationJob::class)->handle();
+
+ $ad->refresh();
+ expect($ad->moderation_status)->toBe('ACCEPTED');
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
+});
+
+it('AdvertisingStopped ставит кампанию тенанта на паузу через реальный слушатель', function () {
+ config([
+ 'services.yandex_direct.enabled' => true,
+ 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
+ 'services.yandex_direct.token' => 'DIRTOKEN',
+ ]);
+ Http::fake([
+ '*/json/v5/campaigns' => Http::response(['result' => []], 200),
+ ]);
+
+ $tenant = Tenant::factory()->create();
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Кампания в работе',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_campaign_id' => 224,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+
+ // Без Event::fake — доказываем, что слушатель реально ЗАРЕГИСТРИРОВАН и сработал.
+ event(new AdvertisingStopped($tenant->id));
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/json/v5/campaigns')
+ && ($request->data()['method'] ?? null) === 'suspend';
+ });
+});
+
+it('AdvertisingStopped не трогает кампанию чужого тенанта (явный tenant-фильтр)', function () {
+ config([
+ 'services.yandex_direct.enabled' => true,
+ 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
+ 'services.yandex_direct.token' => 'DIRTOKEN',
+ ]);
+ Http::fake([
+ '*/json/v5/campaigns' => Http::response(['result' => []], 200),
+ ]);
+
+ $tenantA = Tenant::factory()->create();
+ $tenantB = Tenant::factory()->create();
+
+ $campaignA = AdCampaign::create([
+ 'tenant_id' => $tenantA->id,
+ 'name' => 'Кампания тенанта A',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_campaign_id' => 225,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+ $campaignB = AdCampaign::create([
+ 'tenant_id' => $tenantB->id,
+ 'name' => 'Кампания тенанта B',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'yandex_campaign_id' => 226,
+ 'weekly_budget_rub' => '500.00',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ ]);
+
+ event(new AdvertisingStopped($tenantA->id));
+
+ $campaignA->refresh();
+ $campaignB->refresh();
+
+ expect($campaignA->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
+ expect($campaignB->status)->toBe(AdCampaign::STATUS_RUNNING);
+});
From 314c8ce5a23745473582bd1a83c6746641f071b1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 23:55:54 +0300
Subject: [PATCH 024/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20API=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B9=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20(CR?=
=?UTF-8?q?UD=20+=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D0=B0?=
=?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20+=20=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20+=20=D0=BA=D1=80=D0=B5=D0=B0?=
=?UTF-8?q?=D1=82=D0=B8=D0=B2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Api/AdvertisingCampaignController.php | 242 +++++++++++++
app/routes/web.php | 11 +
.../AdvertisingCampaignEndpointTest.php | 324 ++++++++++++++++++
3 files changed, 577 insertions(+)
create mode 100644 app/app/Http/Controllers/Api/AdvertisingCampaignController.php
create mode 100644 app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
new file mode 100644
index 00000000..49b8122a
--- /dev/null
+++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
@@ -0,0 +1,242 @@
+user()->tenant_id; все запросы к ad_* — с явным
+ * ->where('tenant_id', ...) поверх RLS (defense-in-depth, в тестах PG superuser
+ * BYPASSRLS) — паттерн из AdvertisingWalletController/TenantChargesController.
+ */
+class AdvertisingCampaignController extends Controller
+{
+ public function index(Request $request): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaigns = AdCampaign::where('tenant_id', $tenantId)
+ ->orderByDesc('id')
+ ->get(['id', 'name', 'status', 'weekly_budget_rub', 'audience_days', 'launched_at']);
+
+ return response()->json(['data' => $campaigns]);
+ }
+
+ public function store(Request $request): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:255'],
+ // TODO(бизнес): уточнить минимальный недельный бюджет клиента (после ÷1.3
+ // у Яндекса — фактический минимум площадки), Р8/В-open — НЕ хардкодить
+ // выдуманное число. Пока только "> 0".
+ 'audience_days' => ['required', 'integer', 'min:1', 'max:90'],
+ 'use_uploaded_list' => ['boolean'],
+ 'weekly_budget_rub' => ['required', 'numeric', 'min:1'],
+ 'daily_budget_rub' => ['nullable', 'numeric', 'min:1'],
+ 'click_bid_rub' => ['nullable', 'numeric', 'min:1'],
+ ]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $tenantId,
+ 'status' => AdCampaign::STATUS_DRAFT,
+ 'name' => $data['name'],
+ 'audience_days' => $data['audience_days'],
+ 'use_uploaded_list' => $data['use_uploaded_list'] ?? false,
+ 'weekly_budget_rub' => $data['weekly_budget_rub'],
+ 'daily_budget_rub' => $data['daily_budget_rub'] ?? null,
+ 'click_bid_rub' => $data['click_bid_rub'] ?? null,
+ ]);
+
+ return response()->json($campaign, 201);
+ }
+
+ public function show(Request $request, int $id): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+ $ads = AdCampaignAd::where('tenant_id', $tenantId)->where('campaign_id', $campaign->id)->get();
+
+ $chargeAmounts = AdWalletTransaction::where('tenant_id', $tenantId)
+ ->where('type', AdWalletTransaction::TYPE_CHARGE)
+ ->where('channel', 'yandex')
+ ->where('related_type', 'campaign')
+ ->where('related_id', $campaign->id)
+ ->pluck('amount_rub');
+
+ $spentRub = '0.00';
+ foreach ($chargeAmounts as $amount) {
+ $amount = (string) $amount;
+ $abs = bccomp($amount, '0', 2) < 0 ? bcmul($amount, '-1', 2) : $amount;
+ $spentRub = bcadd($spentRub, $abs, 2);
+ }
+
+ return response()->json([
+ 'campaign' => $campaign,
+ 'ads' => $ads,
+ 'spent_rub' => $spentRub,
+ ]);
+ }
+
+ public function update(Request $request, int $id): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+
+ $data = $request->validate([
+ 'name' => ['sometimes', 'required', 'string', 'max:255'],
+ 'audience_days' => ['sometimes', 'required', 'integer', 'min:1', 'max:90'],
+ 'use_uploaded_list' => ['sometimes', 'boolean'],
+ 'weekly_budget_rub' => ['sometimes', 'required', 'numeric', 'min:1'],
+ 'daily_budget_rub' => ['sometimes', 'nullable', 'numeric', 'min:1'],
+ 'click_bid_rub' => ['sometimes', 'nullable', 'numeric', 'min:1'],
+ ]);
+
+ // Правка аудитории/списка (audience_days, use_uploaded_list) применяется на
+ // следующий день ночным replace-джобом (Р30) — здесь только сохраняем поле,
+ // текущий прогон кампании её не подхватывает. Бюджет — применяется сразу.
+ $campaign->update($data);
+
+ return response()->json($campaign->fresh());
+ }
+
+ public function audienceSize(Request $request, int $id, CampaignAudienceBuilder $builder): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+
+ $data = $request->validate([
+ 'days' => ['nullable', 'integer', 'min:1', 'max:90'],
+ ]);
+
+ // Живой счётчик (Р22-Р23): временно проставляем audience_days на инстансе,
+ // НЕ сохраняя в БД (нет вызова ->save()).
+ $campaign->audience_days = $data['days'] ?? $campaign->audience_days;
+
+ $size = $builder->size($campaign);
+ $enough = $size >= 100;
+
+ return response()->json([
+ 'size' => $size,
+ 'min' => 100,
+ 'enough' => $enough,
+ 'hint' => $enough ? null : 'Аудитория меньше 100 — увеличьте число дней или добавьте свой список',
+ ]);
+ }
+
+ public function launch(Request $request, int $id, CampaignLauncher $launcher): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+
+ try {
+ $launcher->launch($campaign);
+ } catch (AudienceTooSmallException $e) {
+ return response()->json([
+ 'message' => "Аудитория слишком мала: {$e->size}. Нужно минимум 100 — увеличьте дни или добавьте свой список.",
+ ], 422);
+ } catch (RuntimeException $e) {
+ return response()->json(['message' => $e->getMessage()], 409);
+ }
+
+ return response()->json(['status' => $campaign->fresh()->status]);
+ }
+
+ public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+
+ $data = $request->validate([
+ 'title' => ['required', 'string', 'max:56'],
+ 'text' => ['required', 'string', 'max:120'],
+ 'href' => ['required', 'url', 'max:1024'],
+ 'title2' => ['nullable', 'string', 'max:60'],
+ ]);
+
+ $errors = $validator->validateText($data['title'], $data['text'], $data['title2'] ?? null);
+ if ($errors !== []) {
+ return response()->json(['errors' => $errors], 422);
+ }
+
+ $ad = AdCampaignAd::create([
+ 'tenant_id' => $tenantId,
+ 'campaign_id' => $campaign->id,
+ 'title' => $data['title'],
+ 'text' => $data['text'],
+ 'href' => $data['href'],
+ 'title2' => $data['title2'] ?? null,
+ 'moderation_status' => 'draft',
+ ]);
+
+ return response()->json($ad, 201);
+ }
+
+ public function uploadAdImage(Request $request, int $id, int $adId, CreativeValidator $validator): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+ $ad = AdCampaignAd::where('tenant_id', $tenantId)
+ ->where('campaign_id', $campaign->id)
+ ->where('id', $adId)
+ ->firstOrFail();
+
+ $request->validate([
+ 'file' => ['required', 'image', 'max:10240'],
+ ]);
+
+ /** @var UploadedFile $file */
+ $file = $request->file('file');
+ $path = $file->getRealPath() ?: $file->getPathname();
+
+ $size = @getimagesize($path);
+ $width = (int) ($size[0] ?? 0);
+ $height = (int) ($size[1] ?? 0);
+
+ $errors = $validator->validateImage($width, $height, (string) $file->getMimeType(), (int) $file->getSize());
+ if ($errors !== []) {
+ return response()->json(['errors' => $errors], 422);
+ }
+
+ if (config('services.yandex_direct.enabled') === false) {
+ return response()->json(['message' => 'Яндекс.Директ выключен — картинку пока не загрузить.'], 409);
+ }
+
+ $direct = new YandexDirectClient(
+ (string) config('services.yandex_direct.base_url'),
+ (string) config('services.yandex_direct.token'),
+ );
+
+ $base64 = base64_encode((string) file_get_contents($path));
+ $hash = $direct->uploadAdImage($file->getClientOriginalName(), $base64);
+
+ $ad->update(['image_normal_hash' => $hash]);
+
+ return response()->json(['hash' => $hash]);
+ }
+}
diff --git a/app/routes/web.php b/app/routes/web.php
index a86702c7..2beb3c3c 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -380,6 +380,17 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun
// рекламный кошелёк» (Task 9, часть B). RLS на ad_wallets требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group(function () {
Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show');
+
+ // Кампании Директа для клиентского портала (Часть A, Task 12) — CRUD-черновик +
+ // счётчик аудитории + запуск + креатив. Фронт (Часть B2) — отдельная задача.
+ Route::get('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@index');
+ Route::post('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@store');
+ Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@show')->whereNumber('id');
+ Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id');
+ Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id');
+ Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id');
+ Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id');
+ Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']);
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
new file mode 100644
index 00000000..1e2d1c16
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
@@ -0,0 +1,324 @@
+create().
+ */
+beforeEach(function () {
+ $this->tenant = Tenant::factory()->create();
+ $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $this->actingAs($this->user);
+});
+
+function seedCampaignPhones(AdCampaign $campaign, int $count): void
+{
+ $rows = [];
+ for ($i = 0; $i < $count; $i++) {
+ $rows[] = [
+ 'tenant_id' => $campaign->tenant_id,
+ 'campaign_id' => $campaign->id,
+ 'phone' => sprintf('798800%05d', $i),
+ 'expires_at' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ];
+ }
+ DB::table('ad_campaign_phones')->insert($rows);
+}
+
+it('returns 401 without auth', function () {
+ auth()->logout();
+
+ $this->getJson('/api/advertising/campaigns')->assertStatus(401);
+});
+
+it('creates a draft campaign with valid body', function () {
+ $response = $this->postJson('/api/advertising/campaigns', [
+ 'name' => 'Кампания А',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1500.00',
+ ]);
+
+ $response->assertCreated();
+
+ $campaignId = $response->json('id');
+ expect($campaignId)->not->toBeNull();
+
+ $this->assertDatabaseHas('ad_campaigns', [
+ 'id' => $campaignId,
+ 'tenant_id' => $this->tenant->id,
+ 'status' => AdCampaign::STATUS_DRAFT,
+ ]);
+});
+
+it('rejects an invalid body with 422', function () {
+ $response = $this->postJson('/api/advertising/campaigns', [
+ 'name' => '',
+ 'audience_days' => 0,
+ 'weekly_budget_rub' => '1500.00',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['name', 'audience_days']);
+});
+
+it('lists tenant campaigns and shows one with ads and spent_rub', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания B',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $index = $this->getJson('/api/advertising/campaigns');
+ $index->assertOk();
+ expect(collect($index->json('data'))->pluck('id')->all())->toContain($campaign->id);
+
+ $show = $this->getJson("/api/advertising/campaigns/{$campaign->id}");
+ $show->assertOk()
+ ->assertJsonPath('campaign.id', $campaign->id)
+ ->assertJsonPath('ads', [])
+ ->assertJsonPath('spent_rub', '0.00');
+});
+
+it('sums charge transactions as spent_rub', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания расход',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+ AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '1000.00', 'frozen_rub' => '0.00']);
+
+ AdWalletTransaction::create([
+ 'tenant_id' => $this->tenant->id,
+ 'type' => AdWalletTransaction::TYPE_CHARGE,
+ 'amount_rub' => '-260.00',
+ 'balance_rub_after' => '740.00',
+ 'channel' => 'yandex',
+ 'related_type' => 'campaign',
+ 'related_id' => $campaign->id,
+ 'external_key' => 'yandex:'.$campaign->id.':2026-07-20',
+ 'created_at' => now(),
+ ]);
+ AdWalletTransaction::create([
+ 'tenant_id' => $this->tenant->id,
+ 'type' => AdWalletTransaction::TYPE_CHARGE,
+ 'amount_rub' => '-40.00',
+ 'balance_rub_after' => '700.00',
+ 'channel' => 'yandex',
+ 'related_type' => 'campaign',
+ 'related_id' => $campaign->id,
+ 'external_key' => 'yandex:'.$campaign->id.':2026-07-21',
+ 'created_at' => now(),
+ ]);
+
+ $show = $this->getJson("/api/advertising/campaigns/{$campaign->id}");
+ $show->assertOk()->assertJsonPath('spent_rub', '300.00');
+});
+
+it('updates a campaign partially', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'До правки',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
+ 'name' => 'После правки',
+ 'weekly_budget_rub' => '2000.00',
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('name', 'После правки')
+ ->assertJsonPath('weekly_budget_rub', '2000.00');
+
+ $this->assertDatabaseHas('ad_campaigns', [
+ 'id' => $campaign->id,
+ 'name' => 'После правки',
+ 'weekly_budget_rub' => '2000.00',
+ ]);
+});
+
+it('reports a small audience as not enough with a hint', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Малая аудитория',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => false,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10");
+
+ $response->assertOk()
+ ->assertJsonPath('min', 100)
+ ->assertJsonPath('enough', false);
+ expect($response->json('size'))->toBeLessThan(100);
+ expect($response->json('hint'))->not->toBeNull();
+});
+
+it('reports a large audience as enough with no hint', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Большая аудитория',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => true,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+ seedCampaignPhones($campaign, 150);
+
+ $response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10");
+
+ $response->assertOk()
+ ->assertJsonPath('enough', true)
+ ->assertJsonPath('hint', null);
+ expect($response->json('size'))->toBeGreaterThanOrEqual(100);
+});
+
+it('rejects a bad ad creative with 422 errors.title', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания под объявление',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [
+ 'title' => str_repeat('а', 60),
+ 'text' => 'Обычный текст объявления',
+ 'href' => 'https://liderra.ru',
+ ]);
+
+ $response->assertStatus(422);
+ expect($response->json('errors.title'))->not->toBeNull();
+});
+
+it('creates a valid ad creative', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания под объявление 2',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [
+ 'title' => 'Хороший заголовок',
+ 'text' => 'Хороший текст объявления в пределах нормы',
+ 'href' => 'https://liderra.ru',
+ ]);
+
+ $response->assertCreated();
+ $this->assertDatabaseHas('ad_campaign_ads', [
+ 'campaign_id' => $campaign->id,
+ 'tenant_id' => $this->tenant->id,
+ 'title' => 'Хороший заголовок',
+ ]);
+});
+
+it('returns 422 with a message when launching with a too-small audience', function () {
+ config(['services.yandex_direct.enabled' => true]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Запуск малая аудитория',
+ 'audience_days' => 10,
+ 'use_uploaded_list' => false,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch");
+
+ $response->assertStatus(422);
+ expect($response->json('message'))->not->toBeNull();
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
+});
+
+it('uploads a valid ad image and stores the returned hash', function () {
+ config(['services.yandex_direct.enabled' => true]);
+ config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
+ config(['services.yandex_direct.token' => 'DIRTOKEN']);
+ Http::fake(['*/json/v501/adimages' => Http::response(['result' => ['AddResults' => [['AdImageHash' => 'HASH123']]]])]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания под картинку',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+ $ad = AdCampaignAd::create([
+ 'tenant_id' => $this->tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'title' => 'Заголовок',
+ 'text' => 'Текст объявления',
+ 'href' => 'https://liderra.ru',
+ 'moderation_status' => 'draft',
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [
+ 'file' => UploadedFile::fake()->image('creative.jpg', 500, 500),
+ ]);
+
+ $response->assertOk()->assertJsonPath('hash', 'HASH123');
+ $this->assertDatabaseHas('ad_campaign_ads', [
+ 'id' => $ad->id,
+ 'image_normal_hash' => 'HASH123',
+ ]);
+});
+
+it('rejects an ad image upload when yandex_direct is disabled', function () {
+ config(['services.yandex_direct.enabled' => false]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания под картинку 2',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+ $ad = AdCampaignAd::create([
+ 'tenant_id' => $this->tenant->id,
+ 'campaign_id' => $campaign->id,
+ 'title' => 'Заголовок',
+ 'text' => 'Текст объявления',
+ 'href' => 'https://liderra.ru',
+ 'moderation_status' => 'draft',
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [
+ 'file' => UploadedFile::fake()->image('creative.jpg', 500, 500),
+ ]);
+
+ $response->assertStatus(409);
+});
+
+it('isolates tenants: user of tenant A cannot see tenant B campaign', function () {
+ $tenantB = Tenant::factory()->create();
+ $campaignB = AdCampaign::create([
+ 'tenant_id' => $tenantB->id,
+ 'name' => 'Чужая кампания',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ ]);
+
+ $response = $this->getJson("/api/advertising/campaigns/{$campaignB->id}");
+
+ $response->assertStatus(404);
+});
From 7e0b8e53aa308496d5eacdee2a725b5707a4c701 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 00:07:45 +0300
Subject: [PATCH 025/147] =?UTF-8?q?fix(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=B4=D0=B6=D0=BE=D0=B1=D1=8B=20=D0=94=D0=B8?=
=?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=87?=
=?UTF-8?q?=D0=B8=D1=81=D0=BB=D1=8F=D1=8E=D1=82=20=D0=BA=D0=B0=D0=BC=D0=BF?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20pgs?=
=?UTF-8?q?ql=5Fsupplier=20(BYPASSRLS)=20+=20SET=20LOCAL=20=D0=B4=D0=BB?=
=?UTF-8?q?=D1=8F=20=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B9=20?=
=?UTF-8?q?=E2=80=94=20=D0=B8=D0=BD=D0=B0=D1=87=D0=B5=20=D0=BD=D0=B0=20?=
=?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D0=B5=20RLS=20=D0=B4=D0=B0=D1=91=D1=82?=
=?UTF-8?q?=200=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20(=D0=BC=D0=BE=D0=BB?=
=?UTF-8?q?=D1=87=D0=B0=D0=BB=D0=B8=D0=B2=D1=8B=D0=B9=20=D1=81=D0=B1=D0=BE?=
=?UTF-8?q?=D0=B9)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/app/Jobs/ChargeCampaignSpendJob.php | 61 +++++++++++--------
app/app/Jobs/SyncCampaignAudienceJob.php | 15 +++--
app/app/Jobs/SyncCampaignModerationJob.php | 11 +++-
.../CampaignModerationAndStopTest.php | 5 ++
.../ChargeCampaignSpendJobTest.php | 9 +++
.../SyncCampaignAudienceJobTest.php | 5 ++
6 files changed, 74 insertions(+), 32 deletions(-)
diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php
index 67a776a0..342ff4fe 100644
--- a/app/app/Jobs/ChargeCampaignSpendJob.php
+++ b/app/app/Jobs/ChargeCampaignSpendJob.php
@@ -24,11 +24,19 @@ use Throwable;
* Суточное списание расхода Директа по кликам (Cost × наценка) со всех
* тенантов сразу.
*
- * Джоб намеренно обходит кампании ВСЕХ тенантов — очередь на бою бежит под
- * ролью `crm_supplier_worker` (BYPASSRLS), RLS здесь не фильтрует, а деньги
- * — это правильное поведение (обслуживаем всех разом). Денежные операции
- * (charge/isSolvent/stopAll) всегда принимают tenant_id явным аргументом —
- * никакого смешения кошельков между тенантами.
+ * 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
+ * соединении, БЕЗ tenant-контекста. Перечисление кампаний через дефолтное
+ * соединение без контекста дало бы 0 строк по RLS (молчаливый сбой), а чтение
+ * кошелька внутри AdWalletService/AdWalletGate/AdStopAllService вообще упало бы
+ * ModelNotFound. Поэтому:
+ * — кампании перечисляем плоскими строками через `pgsql_supplier` (BYPASSRLS,
+ * аналог SyncSupplierProjectsJob/SendNewLeadsDigestJob), БЕЗ открытой
+ * транзакции — не держим транзакцию во время сетевого запроса к Директу;
+ * — денежная операция (charge/isSolvent/stopAll) — под tenant-контекстом
+ * (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ соединении, где живут
+ * AdWallet/AdWalletTransaction; тогда crm_app_user с контекстом проходит
+ * RLS корректно. Денежные операции всегда принимают tenant_id явным
+ * аргументом — никакого смешения кошельков между тенантами.
*
* Рубильник: пока services.yandex_direct.enabled=false — джоб не делает ни
* одного обращения к Яндексу и ничего не списывает.
@@ -51,43 +59,48 @@ class ChargeCampaignSpendJob implements ShouldQueue
$stopAll = app(AdStopAllService::class);
$date = Carbon::now()->subDay()->toDateString();
- $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00'));
+ $markup = new AdMarkup((string) (DB::connection('pgsql_supplier')->table('ad_settings')->value('markup_percent') ?? '30.00'));
$direct = new YandexDirectClient(
$this->configString('services.yandex_direct.base_url'),
$this->configString('services.yandex_direct.token'),
);
- $campaigns = AdCampaign::where('status', AdCampaign::STATUS_RUNNING)
+ $rows = DB::connection('pgsql_supplier')->table('ad_campaigns')
+ ->where('status', AdCampaign::STATUS_RUNNING)
->whereNotNull('yandex_campaign_id')
- ->get();
+ ->get(['id', 'tenant_id', 'yandex_campaign_id']);
- foreach ($campaigns as $campaign) {
+ foreach ($rows as $row) {
try {
- $spend = $direct->getCampaignSpend((int) $campaign->yandex_campaign_id, $date);
+ $spend = $direct->getCampaignSpend((int) $row->yandex_campaign_id, $date);
$clientCost = $markup->clientFromYandex($spend['cost']);
if (bccomp($clientCost, '0', 2) === 0) {
continue;
}
- $tenantId = (int) $campaign->tenant_id;
+ $tenantId = (int) $row->tenant_id;
- $wallet->charge(
- $tenantId,
- 'yandex',
- 'campaign',
- (int) $campaign->id,
- $clientCost,
- "yandex:{$campaign->id}:{$date}",
- );
+ DB::transaction(function () use ($row, $clientCost, $date, $tenantId, $wallet, $gate, $stopAll): void {
+ DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
- if (! $gate->isSolvent($tenantId)) {
- $stopAll->stopAll($tenantId);
- }
+ $wallet->charge(
+ $tenantId,
+ 'yandex',
+ 'campaign',
+ (int) $row->id,
+ $clientCost,
+ "yandex:{$row->id}:{$date}",
+ );
+
+ if (! $gate->isSolvent($tenantId)) {
+ $stopAll->stopAll($tenantId);
+ }
+ });
} catch (Throwable $e) {
Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [
- 'campaign_id' => $campaign->id,
- 'tenant_id' => $campaign->tenant_id,
+ 'campaign_id' => $row->id,
+ 'tenant_id' => $row->tenant_id,
'error' => $e->getMessage(),
]);
}
diff --git a/app/app/Jobs/SyncCampaignAudienceJob.php b/app/app/Jobs/SyncCampaignAudienceJob.php
index 5ee62092..4d0aa7ea 100644
--- a/app/app/Jobs/SyncCampaignAudienceJob.php
+++ b/app/app/Jobs/SyncCampaignAudienceJob.php
@@ -22,10 +22,15 @@ use Throwable;
*
* Режим replace: отдаём полный текущий состав, повторный запуск безопасен.
*
- * 🔴 Бежит на роли `crm_supplier_worker` (BYPASSRLS) и намеренно обходит кампании
- * ВСЕХ тенантов разом — так задумано (аналог SyncAdAudienceJob). Utenant-утечку
- * закрывает сам CampaignAudienceBuilder::build() явным where(tenant_id) внутри —
- * этому джобу дополнительно фильтровать тенантов не нужно.
+ * 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
+ * соединении, БЕЗ tenant-контекста — без явного перечисления через BYPASSRLS-
+ * соединение RLS-политика (`tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`)
+ * при пустом контексте даёт 0 строк, и джоб молча ничего не делает. Поэтому
+ * перечисляем кампании через `pgsql_supplier` (BYPASSRLS, аналог
+ * SyncSupplierProjectsJob/SendNewLeadsDigestJob); аудитория каждой кампании
+ * строится билдером в его СОБСТВЕННОМ tenant-контексте — CampaignAudienceBuilder::build()
+ * сам ставит SET LOCAL на дефолтном соединении, этому джобу дополнительно
+ * фильтровать тенантов не нужно.
*
* Рубильник: services.yandex_direct.enabled — пока выключен, джоб не делает
* ни одного обращения к Яндексу.
@@ -54,7 +59,7 @@ class SyncCampaignAudienceJob implements ShouldQueue
$builder = app(CampaignAudienceBuilder::class);
$client = new YandexAudienceClient($token);
- $campaigns = AdCampaign::whereIn('status', [
+ $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [
AdCampaign::STATUS_RUNNING,
AdCampaign::STATUS_PENDING_MODERATION,
])->whereNotNull('yandex_segment_id')->get();
diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php
index 84bbed3b..bd831425 100644
--- a/app/app/Jobs/SyncCampaignModerationJob.php
+++ b/app/app/Jobs/SyncCampaignModerationJob.php
@@ -22,8 +22,13 @@ use Throwable;
* кампанию (rejected), все ACCEPTED — переводит в running, иначе кампания
* остаётся pending_moderation (ждём).
*
- * 🔴 Бежит на роли `crm_supplier_worker` (BYPASSRLS) и намеренно обходит
- * кампании ВСЕХ тенантов разом — аналог SyncCampaignAudienceJob/ChargeCampaignSpendJob.
+ * 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
+ * соединении, БЕЗ tenant-контекста — без явного перечисления через BYPASSRLS-
+ * соединение RLS-политика при пустом контексте даёт 0 строк, и джоб молча ничего
+ * не делает. Перечисляем кампании через `pgsql_supplier` (BYPASSRLS, аналог
+ * SyncCampaignAudienceJob/ChargeCampaignSpendJob); модели загружены через это
+ * соединение — их ->update() (ad и campaign) идут по BYPASSRLS, пишем строго
+ * по загруженным строкам (без доп. tenant-фильтра).
*
* Рубильник: services.yandex_direct.enabled — пока выключен, джоб не делает
* ни одного обращения к Яндексу.
@@ -43,7 +48,7 @@ class SyncCampaignModerationJob implements ShouldQueue
$this->configString('services.yandex_direct.token'),
);
- $campaigns = AdCampaign::whereIn('status', [
+ $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [
AdCampaign::STATUS_RUNNING,
AdCampaign::STATUS_PENDING_MODERATION,
])->whereNotNull('yandex_campaign_id')->with('ads')->get();
diff --git a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
index e8f3791e..c865ce0d 100644
--- a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
+++ b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
@@ -9,10 +9,15 @@ use App\Models\AdCampaignAd;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
+use Tests\Concerns\SharesSupplierPdo;
// Джоб намеренно обходит running/pending-кампании ВСЕХ тенантов — без изоляции
// между кейсами кампания из одного теста осталась бы видна следующему.
uses(RefreshDatabase::class);
+// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
+// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
+// второму соединению до commit'а.
+uses(SharesSupplierPdo::class);
it('модерация: REJECTED объявление помечает кампанию rejected (passthrough)', function () {
config([
diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
index 2d4fd2b7..36fa825f 100644
--- a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
+++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
@@ -9,9 +9,18 @@ use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
+use Tests\Concerns\SharesSupplierPdo;
+
+// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
+// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
+// второму соединению до commit'а. RefreshDatabase раньше не был подключён в
+// этом файле — подключаем оба вместе (см. общее правило других Advertising-тестов).
+uses(RefreshDatabase::class);
+uses(SharesSupplierPdo::class);
function configureYandexDirectForSpend(): void
{
diff --git a/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php b/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
index 8c2f2103..a6d602ba 100644
--- a/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
+++ b/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
@@ -8,11 +8,16 @@ use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
+use Tests\Concerns\SharesSupplierPdo;
// Джоб намеренно обходит running/pending-кампании ВСЕХ тенантов — без изоляции
// между кейсами кампания из одного теста осталась бы видна следующему (проверка
// "пустая аудитория ⇒ ни одного обращения" иначе ловила бы чужой сегмент).
uses(RefreshDatabase::class);
+// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
+// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
+// второму соединению до commit'а.
+uses(SharesSupplierPdo::class);
it('replace-заливает аудиторию активной кампании в Яндекс.Аудитории', function () {
config([
From 60128d48d860950b4789eb762ba49656360170ae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 00:12:40 +0300
Subject: [PATCH 026/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B6=D0=BE=D0=B1=D0=BE=D0=B2=20?=
=?UTF-8?q?=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20(=D0=B0=D1=83?=
=?UTF-8?q?=D0=B4=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=2003:50,=20=D1=81?=
=?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=2004:20,=20=D0=BC?=
=?UTF-8?q?=D0=BE=D0=B4=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=B0?=
=?UTF-8?q?=D0=B6=D0=B4=D1=8B=D0=B5=202=D1=87,=20=D0=9C=D0=A1=D0=9A)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/routes/console.php | 25 +++++++++++++++++++
.../Advertising/AdvertisingScheduleTest.php | 15 +++++++++++
2 files changed, 40 insertions(+)
create mode 100644 app/tests/Feature/Advertising/AdvertisingScheduleTest.php
diff --git a/app/routes/console.php b/app/routes/console.php
index c38b8a60..9e8c9efc 100644
--- a/app/routes/console.php
+++ b/app/routes/console.php
@@ -1,5 +1,6 @@
everyFiveMinutes()
->onSuccess(fn () => $hb->recordRunResult('billing:reconcile-payments', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:reconcile-payments', false, 'Command failed', null));
+
+// Рекламный модуль Директа (Часть B1). Джобы сами перечисляют кампании через
+// pgsql_supplier (BYPASSRLS) — на дефолтной роли crm_app_user очередь без tenant-
+// контекста RLS дала бы 0 строк. Времена МСК, вне пиковых часов и не пересекаются
+// с уже занятыми слотами (03:20 SyncAd, 03:30 SyncVk, 04:00 audit).
+Schedule::job(new SyncCampaignAudienceJob)
+ ->dailyAt('03:50')
+ ->timezone('Europe/Moscow')
+ ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', true, null, null))
+ ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', false, 'Job failed', null));
+
+Schedule::job(new ChargeCampaignSpendJob)
+ ->dailyAt('04:20')
+ ->timezone('Europe/Moscow')
+ ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', true, null, null))
+ ->onFailure(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', false, 'Job failed', null));
+
+Schedule::job(new SyncCampaignModerationJob)
+ ->everyTwoHours()
+ ->timezone('Europe/Moscow')
+ ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', true, null, null))
+ ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', false, 'Job failed', null));
diff --git a/app/tests/Feature/Advertising/AdvertisingScheduleTest.php b/app/tests/Feature/Advertising/AdvertisingScheduleTest.php
new file mode 100644
index 00000000..ced35214
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdvertisingScheduleTest.php
@@ -0,0 +1,15 @@
+events();
+ $summaries = array_map(fn ($e) => $e->getSummaryForDisplay(), $events);
+ $joined = implode("\n", $summaries);
+
+ expect($joined)->toContain('ChargeCampaignSpendJob')
+ ->and($joined)->toContain('SyncCampaignAudienceJob')
+ ->and($joined)->toContain('SyncCampaignModerationJob');
+});
From 5010087b4f1e8f3e2ba80387ccde0b91aaa8a726 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 00:25:01 +0300
Subject: [PATCH 027/147] =?UTF-8?q?fix(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20GRANT=20crm=5Fsupplier=5Fworker=20=D0=BD=D0=B0?=
=?UTF-8?q?=20ad=5Fcampaigns/ad=5Fcampaign=5Fads=20+=20ad=5Fsettings=20?=
=?UTF-8?q?=D0=BF=D0=BE=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D1=83=20?=
=?UTF-8?q?=E2=80=94=20=D0=B8=D0=BD=D0=B0=D1=87=D0=B5=20=D0=B4=D0=B6=D0=BE?=
=?UTF-8?q?=D0=B1=D1=8B=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20?=
=?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82=20permission=20denied=20?=
=?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/app/Jobs/ChargeCampaignSpendJob.php | 2 +-
...0400_grant_supplier_worker_advertising.php | 44 +++++++++++++++++++
db/CHANGELOG_schema.md | 28 ++++++++++++
3 files changed, 73 insertions(+), 1 deletion(-)
create mode 100644 app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php
diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php
index 342ff4fe..3def97a3 100644
--- a/app/app/Jobs/ChargeCampaignSpendJob.php
+++ b/app/app/Jobs/ChargeCampaignSpendJob.php
@@ -59,7 +59,7 @@ class ChargeCampaignSpendJob implements ShouldQueue
$stopAll = app(AdStopAllService::class);
$date = Carbon::now()->subDay()->toDateString();
- $markup = new AdMarkup((string) (DB::connection('pgsql_supplier')->table('ad_settings')->value('markup_percent') ?? '30.00'));
+ $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00'));
$direct = new YandexDirectClient(
$this->configString('services.yandex_direct.base_url'),
$this->configString('services.yandex_direct.token'),
diff --git a/app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php b/app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php
new file mode 100644
index 00000000..3621d507
--- /dev/null
+++ b/app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php
@@ -0,0 +1,44 @@
+ параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.94 (2026-07-25) — GRANT crm_supplier_worker на ad_campaigns/ad_campaign_ads — доступ ночных джобов Директа
+
+rls-reviewer подтвердил дыру: три ночных джоба Директа (Charge/SyncAudience/
+SyncModeration) перечисляют и обновляют `ad_campaigns`/`ad_campaign_ads`
+через соединение `pgsql_supplier` (роль `crm_supplier_worker`, BYPASSRLS).
+BYPASSRLS снимает RLS-политику, но НЕ заменяет табличную привилегию — без
+GRANT на проде было бы `permission denied for table ad_campaigns/
+ad_campaign_ads` (тот же класс сбоя, что sales_prospects 19.07):
+
+```sql
+GRANT SELECT, UPDATE ON ad_campaigns TO crm_supplier_worker;
+GRANT SELECT, UPDATE ON ad_campaign_ads TO crm_supplier_worker;
+```
+
+SELECT — чтение всеми тремя джобами; UPDATE — запись `moderation_status`
+через SyncCampaignModerationJob. Миграция
+`app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php`
+(`down()` — симметричный REVOKE), гард на существование роли (на dev/тест
+роли нет), прогнана на `liderra_testing` — DONE.
+
+Той же задачей `ChargeCampaignSpendJob` переведён на чтение `ad_settings`
+(наценка) по ДЕФОЛТНОМУ соединению вместо `pgsql_supplier` — таблица
+глобальная, без RLS, `crm_app_user` уже имеет `SELECT` из миграции
+2026_07_24_100300; отдельный supplier-грант не нужен.
+
+Структурно: 0 новых таблиц/колонок/индексов, +2 GRANT-выражения.
+Функций/триггеров без изменений.
+
## v8.93 (2026-07-25) — Яндекс-канал, Часть B1, Task 5 — GRANT crm_admin_user на ad_* + модели Eloquent
Закрывает совет B-adv-1 из RLS-ревью Части A: будущему админ-экрану
From ba7a041f46dd6e25e37d27d9e97fc53289a83fdc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 00:43:53 +0300
Subject: [PATCH 028/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20api-=D0=BC?=
=?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20advertising=20(=D0=BA=D0=BE?=
=?UTF-8?q?=D1=88=D0=B5=D0=BB=D1=91=D0=BA,=20=D0=BA=D0=B0=D0=BC=D0=BF?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B8,=20=D1=81=D1=87=D1=91=D1=82=D1=87?=
=?UTF-8?q?=D0=B8=D0=BA,=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA,=20=D0=BA?=
=?UTF-8?q?=D1=80=D0=B5=D0=B0=D1=82=D0=B8=D0=B2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/resources/js/api/advertising.ts | 133 ++++++++++++++++
app/tests/Frontend/advertising-api.spec.ts | 175 +++++++++++++++++++++
2 files changed, 308 insertions(+)
create mode 100644 app/resources/js/api/advertising.ts
create mode 100644 app/tests/Frontend/advertising-api.spec.ts
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
new file mode 100644
index 00000000..aa0bee83
--- /dev/null
+++ b/app/resources/js/api/advertising.ts
@@ -0,0 +1,133 @@
+import { apiClient, ensureCsrfCookie } from './client';
+
+/**
+ * API-модуль рекламного модуля «Яндекс Аудитория» (Часть B2, Task 1).
+ *
+ * Эндпоинты под [auth:sanctum, tenant], префикс /api/advertising (см. Часть B1 —
+ * docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 12,
+ * routes/web.php). GET'ы не требуют CSRF-cookie, мутации (POST/PATCH) — требуют.
+ */
+
+/** Статус кампании Директа (см. App\Models\AdCampaign константы STATUS_*). */
+export type CampaignStatus = 'draft' | 'pending_moderation' | 'running' | 'paused' | 'rejected' | 'stopped_no_funds';
+
+/** Ответ GET /api/advertising/wallet — статус рекламного кошелька тенанта. */
+export interface AdWalletStatus {
+ solvent: boolean;
+ balance_rub: string;
+ frozen_rub: string;
+ free_rub: string;
+}
+
+/** Кампания Директа (строка из GET /api/advertising/campaigns, а также POST/PATCH-ответ). */
+export interface Campaign {
+ id: number;
+ name: string;
+ status: CampaignStatus;
+ weekly_budget_rub: string;
+ audience_days: number;
+ launched_at: string | null;
+}
+
+/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */
+export interface AudienceSize {
+ size: number;
+ min: number;
+ enough: boolean;
+ hint: string | null;
+}
+
+/** Тело POST /api/advertising/campaigns (создание черновика кампании). */
+export interface CampaignCreate {
+ name: string;
+ audience_days: number;
+ use_uploaded_list: boolean;
+ weekly_budget_rub: string;
+ daily_budget_rub?: string | null;
+ click_bid_rub?: string | null;
+}
+
+/** Объявление кампании (ответ POST /api/advertising/campaigns/{id}/ads). */
+export interface AdCreative {
+ id: number;
+ campaign_id: number;
+ title: string;
+ text: string;
+ href: string;
+ title2: string | null;
+ moderation_status: string;
+ image_normal_hash?: string | null;
+}
+
+/** Ответ GET /api/advertising/campaigns/{id} — детали кампании + объявления + расход. */
+export interface CampaignDetail {
+ campaign: Campaign;
+ ads: AdCreative[];
+ spent_rub: string;
+}
+
+/** GET /api/advertising/wallet — статус рекламного кошелька (баланс/заморожено/свободно). */
+export async function fetchWallet(): Promise {
+ const { data } = await apiClient.get('/api/advertising/wallet');
+ return data;
+}
+
+/** GET /api/advertising/campaigns — список кампаний тенанта. */
+export async function fetchCampaigns(): Promise {
+ const { data } = await apiClient.get<{ data: Campaign[] }>('/api/advertising/campaigns');
+ return data.data ?? [];
+}
+
+/** GET /api/advertising/campaigns/{id} — детали кампании (объявления + расход). */
+export async function fetchCampaign(id: number): Promise {
+ const { data } = await apiClient.get(`/api/advertising/campaigns/${id}`);
+ return data;
+}
+
+/** POST /api/advertising/campaigns — создать черновик кампании. */
+export async function createCampaign(payload: CampaignCreate): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post('/api/advertising/campaigns', payload);
+ return data;
+}
+
+/** PATCH /api/advertising/campaigns/{id} — частичная правка кампании. */
+export async function patchCampaign(id: number, payload: Partial): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.patch(`/api/advertising/campaigns/${id}`, payload);
+ return data;
+}
+
+/** GET /api/advertising/campaigns/{id}/audience-size — живой счётчик аудитории. */
+export async function fetchAudienceSize(id: number, days?: number): Promise {
+ const { data } = await apiClient.get(`/api/advertising/campaigns/${id}/audience-size`, {
+ params: days ? { days } : {},
+ });
+ return data;
+}
+
+/** POST /api/advertising/campaigns/{id}/launch — запустить кампанию (модерация Яндекса). */
+export async function launchCampaign(id: number): Promise<{ status: CampaignStatus }> {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`);
+ return data;
+}
+
+/** POST /api/advertising/campaigns/{id}/ads — добавить объявление (текстовый креатив). */
+export async function addCreative(
+ id: number,
+ payload: { title: string; text: string; href: string; title2?: string },
+): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post(`/api/advertising/campaigns/${id}/ads`, payload);
+ return data;
+}
+
+/** POST /api/advertising/campaigns/{id}/ads/{adId}/image — загрузить картинку объявления. */
+export async function uploadCreativeImage(id: number, adId: number, file: File): Promise<{ hash: string }> {
+ await ensureCsrfCookie();
+ const form = new FormData();
+ form.append('file', file);
+ const { data } = await apiClient.post<{ hash: string }>(`/api/advertising/campaigns/${id}/ads/${adId}/image`, form);
+ return data;
+}
diff --git a/app/tests/Frontend/advertising-api.spec.ts b/app/tests/Frontend/advertising-api.spec.ts
new file mode 100644
index 00000000..becd5dc6
--- /dev/null
+++ b/app/tests/Frontend/advertising-api.spec.ts
@@ -0,0 +1,175 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+vi.mock('../../resources/js/api/client', () => ({
+ apiClient: {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ },
+ ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
+}));
+
+import {
+ fetchWallet,
+ fetchCampaigns,
+ fetchCampaign,
+ createCampaign,
+ patchCampaign,
+ fetchAudienceSize,
+ launchCampaign,
+ addCreative,
+ uploadCreativeImage,
+} from '../../resources/js/api/advertising';
+import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
+
+describe('api/advertising', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('fetchWallet() GETs /api/advertising/wallet', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: { solvent: true, balance_rub: '100.00', frozen_rub: '20.00', free_rub: '80.00' },
+ });
+ const w = await fetchWallet();
+ expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/wallet');
+ expect(w.solvent).toBe(true);
+ expect(w.free_rub).toBe('80.00');
+ });
+
+ it('fetchCampaigns() hits GET /api/advertising/campaigns and unwraps data.data', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: {
+ data: [
+ {
+ id: 1,
+ name: 'C',
+ status: 'draft',
+ weekly_budget_rub: '500.00',
+ audience_days: 10,
+ launched_at: null,
+ },
+ ],
+ },
+ });
+ const rows = await fetchCampaigns();
+ expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns');
+ expect(rows).toHaveLength(1);
+ expect(rows[0].name).toBe('C');
+ });
+
+ it('fetchCampaigns() returns [] when data.data missing', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
+ const rows = await fetchCampaigns();
+ expect(rows).toEqual([]);
+ });
+
+ it('fetchCampaign(id) GETs /api/advertising/campaigns/{id} and returns campaign+ads+spent_rub', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: {
+ campaign: {
+ id: 3,
+ name: 'X',
+ status: 'running',
+ weekly_budget_rub: '1000.00',
+ audience_days: 20,
+ launched_at: '2026-07-01',
+ },
+ ads: [],
+ spent_rub: '10.00',
+ },
+ });
+ const detail = await fetchCampaign(3);
+ expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3');
+ expect(detail.campaign.id).toBe(3);
+ expect(detail.spent_rub).toBe('10.00');
+ });
+
+ it('createCampaign() ensures csrf then POSTs /api/advertising/campaigns with payload', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({
+ data: {
+ id: 5,
+ name: 'X',
+ status: 'draft',
+ weekly_budget_rub: '2500.00',
+ audience_days: 10,
+ launched_at: null,
+ },
+ });
+ const payload = { name: 'X', audience_days: 10, use_uploaded_list: false, weekly_budget_rub: '2500.00' };
+ const c = await createCampaign(payload);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns', payload);
+ expect(c.id).toBe(5);
+ });
+
+ it('patchCampaign() ensures csrf then PATCHes /api/advertising/campaigns/{id}', async () => {
+ vi.mocked(apiClient.patch).mockResolvedValue({
+ data: {
+ id: 5,
+ name: 'X2',
+ status: 'draft',
+ weekly_budget_rub: '3000.00',
+ audience_days: 15,
+ launched_at: null,
+ },
+ });
+ const c = await patchCampaign(5, { weekly_budget_rub: '3000.00' });
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.patch).toHaveBeenCalledWith('/api/advertising/campaigns/5', { weekly_budget_rub: '3000.00' });
+ expect(c.name).toBe('X2');
+ });
+
+ it('fetchAudienceSize() passes days as query param when given', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 120, min: 100, enough: true, hint: null } });
+ const s = await fetchAudienceSize(3, 14);
+ expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3/audience-size', {
+ params: { days: 14 },
+ });
+ expect(s.enough).toBe(true);
+ });
+
+ it('fetchAudienceSize() omits days param when not given', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 5, min: 100, enough: false, hint: 'мало' } });
+ const s = await fetchAudienceSize(3);
+ expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3/audience-size', { params: {} });
+ expect(s.enough).toBe(false);
+ });
+
+ it('launchCampaign() ensures csrf then POSTs launch endpoint', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'pending_moderation' } });
+ const res = await launchCampaign(7);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/launch');
+ expect(res.status).toBe('pending_moderation');
+ });
+
+ it('addCreative() ensures csrf then POSTs ads endpoint with payload', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({
+ data: {
+ id: 9,
+ campaign_id: 7,
+ title: 'T',
+ text: 'Txt',
+ href: 'https://x.ru',
+ title2: null,
+ moderation_status: 'draft',
+ },
+ });
+ const payload = { title: 'T', text: 'Txt', href: 'https://x.ru' };
+ const ad = await addCreative(7, payload);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/ads', payload);
+ expect(ad.id).toBe(9);
+ });
+
+ it('uploadCreativeImage() ensures csrf then POSTs multipart FormData to image endpoint', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({ data: { hash: 'abc123' } });
+ const file = new File(['x'], 'pic.png', { type: 'image/png' });
+ const res = await uploadCreativeImage(7, 9, file);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/ads/9/image', expect.any(FormData));
+ const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
+ expect(sentForm.get('file')).toBe(file);
+ expect(res.hash).toBe('abc123');
+ });
+});
From bbbc4b4b90019bc4c172d6a48abbe6c9de385a21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 00:57:03 +0300
Subject: [PATCH 029/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D1=80=D0=BE?=
=?UTF-8?q?=D1=83=D1=82=20/advertising/yandex=20+=20=D0=B2=D0=B8=D1=82?=
=?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B0=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?=
=?UTF-8?q?=D0=B0=20=D0=B2=D0=B5=D0=B4=D1=91=D1=82=20=D0=BD=D0=B0=20=D1=80?=
=?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80?=
=?UTF-8?q?=D0=B0=D0=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Канал «Яндекс Аудитория» в разделе «Рекламные возможности» (сайдбар +
мобильное «Ещё») больше не заглушка: клик ведёт на новый роут
/advertising/yandex со скелетом экрана (заголовок + вкладки «Мои
кампании» / «Новая реклама»). Остальные 4 канала — без изменений
(по-прежнему открывают AdStubDialog).
---
.../js/components/layout/AppMoreDrawer.vue | 15 +++++++-
.../js/components/layout/AppSidebar.vue | 13 ++++++-
.../components/layout/advertisingChannels.ts | 9 ++++-
app/resources/js/router/index.ts | 20 +++++++++-
.../advertising/AdvertisingYandexView.vue | 37 +++++++++++++++++++
.../Frontend/AppMoreDrawerAdvertising.spec.ts | 13 ++++++-
.../Frontend/AppSidebarAdvertising.spec.ts | 19 +++++++++-
.../Frontend/advertising-channels.spec.ts | 11 ++++++
.../Frontend/advertising-yandex-view.spec.ts | 25 +++++++++++++
9 files changed, 153 insertions(+), 9 deletions(-)
create mode 100644 app/resources/js/views/advertising/AdvertisingYandexView.vue
create mode 100644 app/tests/Frontend/advertising-channels.spec.ts
create mode 100644 app/tests/Frontend/advertising-yandex-view.spec.ts
diff --git a/app/resources/js/components/layout/AppMoreDrawer.vue b/app/resources/js/components/layout/AppMoreDrawer.vue
index 509beac7..1ee6d427 100644
--- a/app/resources/js/components/layout/AppMoreDrawer.vue
+++ b/app/resources/js/components/layout/AppMoreDrawer.vue
@@ -9,7 +9,7 @@
*/
import { ref } from 'vue';
import AdStubDialog from './AdStubDialog.vue';
-import { AD_CHANNELS } from './advertisingChannels';
+import { AD_CHANNELS, type AdChannel } from './advertisingChannels';
const open = defineModel('open', { default: false });
@@ -30,6 +30,16 @@ function openAdStub(): void {
open.value = false;
adStubOpen.value = true;
}
+
+// B2-2: канал с route (сейчас — Яндекс Аудитория) ведёт на реальный экран через :to
+// (навигация закрывает «Ещё»); остальные каналы — прежняя заглушка openAdStub.
+function onChannelClick(c: AdChannel): void {
+ if (c.route) {
+ open.value = false;
+ } else {
+ openAdStub();
+ }
+}
@@ -53,8 +63,9 @@ function openAdStub(): void {
:key="c.testid"
:prepend-icon="c.icon"
:title="c.title"
+ :to="c.route"
:data-testid="c.testid"
- @click="openAdStub"
+ @click="onChannelClick(c)"
/>
diff --git a/app/resources/js/components/layout/AppSidebar.vue b/app/resources/js/components/layout/AppSidebar.vue
index cda270ed..da8ac061 100644
--- a/app/resources/js/components/layout/AppSidebar.vue
+++ b/app/resources/js/components/layout/AppSidebar.vue
@@ -62,7 +62,9 @@ const navGroups = computed(() => [
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
- ...(autopodbor.enabled ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []),
+ ...(autopodbor.enabled
+ ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }]
+ : []),
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
{
@@ -78,7 +80,13 @@ const navGroups = computed(() => [
},
{
eyebrow: 'Рекламные возможности',
- items: AD_CHANNELS.map((c) => ({ title: c.title, icon: c.icon, stub: true, testid: c.testid })),
+ // B2-2: канал с route (сейчас — Яндекс Аудитория) ведёт на реальный экран
+ // (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub.
+ items: AD_CHANNELS.map((c) =>
+ c.route
+ ? { title: c.title, icon: c.icon, to: c.route, testid: c.testid }
+ : { title: c.title, icon: c.icon, stub: true, testid: c.testid },
+ ),
},
{
eyebrow: 'Финансы',
@@ -140,6 +148,7 @@ defineExpose({ navGroups });
class="ld-nav-item"
:class="{ 'ld-nav-item--active': route.path === item.to }"
:data-tour="`nav-${item.to!.replace('/', '')}`"
+ :data-testid="item.testid"
>
{{ item.title }}
diff --git a/app/resources/js/components/layout/advertisingChannels.ts b/app/resources/js/components/layout/advertisingChannels.ts
index 5d9ec3bc..c85a3bee 100644
--- a/app/resources/js/components/layout/advertisingChannels.ts
+++ b/app/resources/js/components/layout/advertisingChannels.ts
@@ -9,6 +9,8 @@ export interface AdChannel {
icon: string;
/** data-testid пункта меню — общий для десктопа и мобильного. */
testid: string;
+ /** Часть B2: если задан — пункт ведёт на реальный роут, не на заглушку. */
+ route?: string;
}
export const AD_RELEASE_DATE = '01.09.2026';
@@ -16,7 +18,12 @@ export const AD_RELEASE_DATE = '01.09.2026';
export const AD_CHANNELS: readonly AdChannel[] = [
{ title: 'ИИ колцентр', icon: 'mdi-robot-outline', testid: 'ad-nav-ai-callcenter' },
{ title: 'Рассылка СМС', icon: 'mdi-message-text', testid: 'ad-nav-sms' },
- { title: 'Яндекс Аудитория', icon: 'mdi-target', testid: 'ad-nav-yandex-audience' },
+ {
+ title: 'Яндекс Аудитория',
+ icon: 'mdi-target',
+ testid: 'ad-nav-yandex-audience',
+ route: '/advertising/yandex',
+ },
{ title: 'VK Реклама', icon: 'mdi-bullhorn-outline', testid: 'ad-nav-vk' },
{ title: 'Реклама Телеграм', icon: 'mdi-send-outline', testid: 'ad-nav-telegram' },
];
diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts
index 8dcc7ad3..5fc95024 100644
--- a/app/resources/js/router/index.ts
+++ b/app/resources/js/router/index.ts
@@ -151,7 +151,13 @@ const routes: RouteRecordRaw[] = [
path: '/autopodbor',
name: 'autopodbor',
component: () => import('../views/autopodbor/AutopodborView.vue'),
- meta: { layout: 'app', title: 'Конкурентное поле', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Конкурентное поле' },
+ meta: {
+ layout: 'app',
+ title: 'Конкурентное поле',
+ requiresAuth: true,
+ transition: 'ld-route-fadeup',
+ devLabel: 'Конкурентное поле',
+ },
},
{
path: '/billing',
@@ -166,6 +172,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Биллинг',
},
},
+ {
+ path: '/advertising/yandex',
+ name: 'advertising-yandex',
+ component: () => import('../views/advertising/AdvertisingYandexView.vue'),
+ meta: {
+ layout: 'app',
+ title: 'Яндекс Аудитория',
+ requiresAuth: true,
+ transition: 'ld-route-fadeup',
+ devLabel: 'Реклама · Яндекс',
+ },
+ },
{
path: '/settings',
name: 'settings',
diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue
new file mode 100644
index 00000000..27552c4a
--- /dev/null
+++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
Яндекс Аудитория
+
+
+ Покажите рекламу тем, кто вам интересен, и платите только за переходы
+
+
+
+ Мои кампании
+ Новая реклама
+
+
+
+
+ — скоро —
+
+
+ — скоро —
+
+
+
+
diff --git a/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts b/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts
index 08d353f3..7e689642 100644
--- a/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts
+++ b/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts
@@ -33,6 +33,9 @@ const AD_ITEMS = [
{ testid: 'ad-nav-telegram', title: 'Реклама Телеграм' },
];
+// B2-2: Яндекс — уже реальный роут, не заглушка. Остальные 4 — по-прежнему «В разработке».
+const STUB_ITEMS = AD_ITEMS.filter((i) => i.testid !== 'ad-nav-yandex-audience');
+
describe('AppMoreDrawer — раздел «Рекламные возможности» на телефоне', () => {
it('содержит 5 рекламных пунктов с нужными названиями', async () => {
const wrapper = await setup();
@@ -48,7 +51,7 @@ describe('AppMoreDrawer — раздел «Рекламные возможнос
expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
});
- it.each(AD_ITEMS)('клик по «$title» открывает окно с текстом релиза', async ({ testid }) => {
+ it.each(STUB_ITEMS)('клик по «$title» открывает окно с текстом релиза', async ({ testid }) => {
const wrapper = await setup();
await wrapper.find(`[data-testid="${testid}"]`).trigger('click');
const dialog = wrapper.find('[data-testid="ad-stub-dialog"]');
@@ -56,4 +59,12 @@ describe('AppMoreDrawer — раздел «Рекламные возможнос
expect(dialog.text()).toContain('В разработке');
expect(dialog.text()).toContain('01.09.2026');
});
+
+ // B2-2: Яндекс Аудитория — теперь реальный переход, не заглушка; клик закрывает «Ещё».
+ it('клик по «Яндекс Аудитория» НЕ открывает заглушку и закрывает «Ещё»', async () => {
+ const wrapper = await setup();
+ await wrapper.find('[data-testid="ad-nav-yandex-audience"]').trigger('click');
+ expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
+ expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false]);
+ });
});
diff --git a/app/tests/Frontend/AppSidebarAdvertising.spec.ts b/app/tests/Frontend/AppSidebarAdvertising.spec.ts
index a63cdba7..4548b76b 100644
--- a/app/tests/Frontend/AppSidebarAdvertising.spec.ts
+++ b/app/tests/Frontend/AppSidebarAdvertising.spec.ts
@@ -22,9 +22,11 @@ async function setup(initialRoute = '/deals'): Promise<{ wrapper: VueWrapper; ro
global: {
plugins: [router, vuetify],
stubs: {
+ // B2-2: Яндекс теперь реальный RouterLink — стаб должен пробрасывать
+ // остальные атрибуты (data-testid и т.п.), не только class.
RouterLink: {
props: ['to'],
- template: '',
+ template: '',
inheritAttrs: false,
},
VDialog: { template: '
' },
@@ -42,6 +44,9 @@ const AD_ITEMS = [
{ testid: 'ad-nav-telegram', title: 'Реклама Телеграм' },
];
+// B2-2: Яндекс — уже реальный роут, не заглушка. Остальные 4 — по-прежнему «В разработке».
+const STUB_ITEMS = AD_ITEMS.filter((i) => i.testid !== 'ad-nav-yandex-audience');
+
describe('AppSidebar — раздел «Рекламные возможности»', () => {
beforeEach(() => localStorage.clear());
@@ -68,7 +73,7 @@ describe('AppSidebar — раздел «Рекламные возможност
expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
});
- it.each(AD_ITEMS)('клик по «$title» открывает окно с текстом релиза', async ({ testid }) => {
+ it.each(STUB_ITEMS)('клик по «$title» открывает окно с текстом релиза', async ({ testid }) => {
const { wrapper } = await setup();
await wrapper.find(`[data-testid="${testid}"]`).trigger('click');
const dialog = wrapper.find('[data-testid="ad-stub-dialog"]');
@@ -84,4 +89,14 @@ describe('AppSidebar — раздел «Рекламные возможност
await wrapper.find('[data-testid="ad-stub-ok"]').trigger('click');
expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
});
+
+ // B2-2: Яндекс Аудитория — теперь реальный переход, не заглушка.
+ it('пункт «Яндекс Аудитория» — ссылка на /advertising/yandex, клик НЕ открывает заглушку', async () => {
+ const { wrapper } = await setup();
+ const link = wrapper.find('[data-testid="ad-nav-yandex-audience"]');
+ expect(link.attributes('href')).toBe('/advertising/yandex');
+
+ await link.trigger('click');
+ expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
+ });
});
diff --git a/app/tests/Frontend/advertising-channels.spec.ts b/app/tests/Frontend/advertising-channels.spec.ts
new file mode 100644
index 00000000..27995e4f
--- /dev/null
+++ b/app/tests/Frontend/advertising-channels.spec.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'vitest';
+import { AD_CHANNELS } from '../../resources/js/components/layout/advertisingChannels';
+
+describe('AD_CHANNELS', () => {
+ it('yandex audience is a real route now, others remain stubs', () => {
+ const yandex = AD_CHANNELS.find((c) => c.testid === 'ad-nav-yandex-audience');
+ expect(yandex?.route).toBe('/advertising/yandex');
+ const stubs = AD_CHANNELS.filter((c) => c.testid !== 'ad-nav-yandex-audience');
+ expect(stubs.every((c) => !c.route)).toBe(true);
+ });
+});
diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts
new file mode 100644
index 00000000..8eee5ee8
--- /dev/null
+++ b/app/tests/Frontend/advertising-yandex-view.spec.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';
+
+const vuetify = createVuetify();
+
+describe('AdvertisingYandexView.vue', () => {
+ const factory = () =>
+ mount(AdvertisingYandexView, {
+ global: { plugins: [vuetify] },
+ });
+
+ it('рендерит корень экрана', () => {
+ const w = factory();
+ expect(w.find('[data-testid="advertising-yandex-view"]').exists()).toBe(true);
+ });
+
+ it('показывает обе вкладки', () => {
+ const w = factory();
+ const text = w.text();
+ expect(text).toContain('Мои кампании');
+ expect(text).toContain('Новая реклама');
+ });
+});
From b468d6683fd3e5aa8a7531d39b7824e92580ec9e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 01:05:11 +0300
Subject: [PATCH 030/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D1=88=D0=B0?=
=?UTF-8?q?=D0=BF=D0=BA=D0=B0-=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=91=D0=BA?=
=?UTF-8?q?=20(=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81/=D0=B7=D0=B0=D0=BC?=
=?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B6=D0=B5=D0=BD=D0=BE/=D1=81=D0=B2=D0=BE?=
=?UTF-8?q?=D0=B1=D0=BE=D0=B4=D0=BD=D0=BE=20+=20=D0=B0=D0=BB=D0=B5=D1=80?=
=?UTF-8?q?=D1=82=20=D0=BD=D0=B5=D1=85=D0=B2=D0=B0=D1=82=D0=BA=D0=B8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/advertising/AdWalletHeader.vue | 140 ++++++++++++++++++
.../advertising/AdvertisingYandexView.vue | 3 +
.../advertising-wallet-header.spec.ts | 92 ++++++++++++
.../Frontend/advertising-yandex-view.spec.ts | 15 +-
4 files changed, 249 insertions(+), 1 deletion(-)
create mode 100644 app/resources/js/components/advertising/AdWalletHeader.vue
create mode 100644 app/tests/Frontend/advertising-wallet-header.spec.ts
diff --git a/app/resources/js/components/advertising/AdWalletHeader.vue b/app/resources/js/components/advertising/AdWalletHeader.vue
new file mode 100644
index 00000000..9ac1f9b6
--- /dev/null
+++ b/app/resources/js/components/advertising/AdWalletHeader.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue
index 27552c4a..a7eec093 100644
--- a/app/resources/js/views/advertising/AdvertisingYandexView.vue
+++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue
@@ -6,6 +6,7 @@
* в resources/js/api/advertising.ts.
*/
import { ref } from 'vue';
+import AdWalletHeader from '../../components/advertising/AdWalletHeader.vue';
const tab = ref('campaigns');
@@ -20,6 +21,8 @@ const tab = ref('campaigns');
Покажите рекламу тем, кто вам интересен, и платите только за переходы
+
+
Мои кампании
Новая реклама
diff --git a/app/tests/Frontend/advertising-wallet-header.spec.ts b/app/tests/Frontend/advertising-wallet-header.spec.ts
new file mode 100644
index 00000000..3b1ed0cf
--- /dev/null
+++ b/app/tests/Frontend/advertising-wallet-header.spec.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+
+const { pushMock, fetchWalletMock } = vi.hoisted(() => ({
+ pushMock: vi.fn(),
+ fetchWalletMock: vi.fn(),
+}));
+
+vi.mock('vue-router', () => ({
+ useRouter: vi.fn(() => ({ push: pushMock })),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ fetchWallet: fetchWalletMock,
+}));
+
+import AdWalletHeader from '../../resources/js/components/advertising/AdWalletHeader.vue';
+
+const vuetify = createVuetify();
+
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+describe('AdWalletHeader.vue (B2-3 — шапка-кошелёк)', () => {
+ beforeEach(() => {
+ pushMock.mockClear();
+ fetchWalletMock.mockReset();
+ });
+
+ it('показывает баланс/заморожено/свободно и НЕ показывает алерт, когда solvent=true', async () => {
+ fetchWalletMock.mockResolvedValue({
+ solvent: true,
+ balance_rub: '18400.00',
+ frozen_rub: '2500.00',
+ free_rub: '15900.00',
+ });
+ const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ // Intl.NumberFormat('ru-RU') разделяет тысячи неразрывным пробелом (U+00A0) — матчим любым \s.
+ expect(w.find('[data-testid="wallet-balance"]').text()).toMatch(/18\s400/);
+ expect(w.find('[data-testid="wallet-frozen"]').text()).toMatch(/2\s500/);
+ expect(w.find('[data-testid="wallet-free"]').text()).toMatch(/15\s900/);
+ expect(w.find('[data-testid="wallet-insolvent-alert"]').exists()).toBe(false);
+ });
+
+ it('показывает заметный алерт «Пополните рекламный кошелёк», когда solvent=false', async () => {
+ fetchWalletMock.mockResolvedValue({
+ solvent: false,
+ balance_rub: '0.00',
+ frozen_rub: '0.00',
+ free_rub: '0.00',
+ });
+ const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ const alert = w.find('[data-testid="wallet-insolvent-alert"]');
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toContain('Пополните рекламный кошелёк');
+ expect(alert.text()).toContain('реклама остановлена');
+ });
+
+ it('кнопка «Пополнить рекламный кошелёк» ведёт на /billing', async () => {
+ fetchWalletMock.mockResolvedValue({
+ solvent: true,
+ balance_rub: '18400.00',
+ frozen_rub: '2500.00',
+ free_rub: '15900.00',
+ });
+ const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await w.find('[data-testid="wallet-topup-btn"]').trigger('click');
+ expect(pushMock).toHaveBeenCalledWith('/billing');
+ });
+
+ it('ошибка загрузки не рушит экран — тихий inline-текст вместо сумм', async () => {
+ fetchWalletMock.mockRejectedValue(new Error('network fail'));
+ const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.find('[data-testid="wallet-balance"]').exists()).toBe(false);
+ expect(w.find('[data-testid="wallet-insolvent-alert"]').exists()).toBe(false);
+ expect(w.find('[data-testid="wallet-load-error"]').exists()).toBe(true);
+ });
+});
diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts
index 8eee5ee8..b95ca315 100644
--- a/app/tests/Frontend/advertising-yandex-view.spec.ts
+++ b/app/tests/Frontend/advertising-yandex-view.spec.ts
@@ -1,6 +1,19 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
+
+// AdvertisingYandexView теперь рендерит AdWalletHeader (B2-3), который дёргает
+// fetchWallet() и useRouter() при монтировании — мокаем оба, чтобы не ходить в сеть.
+vi.mock('vue-router', () => ({
+ useRouter: vi.fn(() => ({ push: vi.fn() })),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ fetchWallet: vi.fn(() =>
+ Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
+ ),
+}));
+
import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';
const vuetify = createVuetify();
From 7cd689f897b7fbaf1ee2d96215c4baf44dca98cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 01:14:30 +0300
Subject: [PATCH 031/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=B2=D0=BA?=
=?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=9C=D0=BE=D0=B8=20?=
=?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=C2=BB=20?=
=?UTF-8?q?=E2=80=94=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20?=
=?UTF-8?q?=D1=81=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0=D0=BC?=
=?UTF-8?q?=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CampaignList.vue: карточки кампаний Яндекс Аудитории (B2-4) — статус-чип
по словарю (running/pending_moderation/rejected/stopped_no_funds/paused/
draft), недельный бюджет, дата запуска; «Пауза»/«Отчёт» — disabled-заглушки
(нет эндпоинтов в B1); пустое состояние с кнопкой «Новая реклама».
AdvertisingYandexView.vue: вкладка campaigns теперь рендерит CampaignList,
события new/edit переключают вкладку. TDD: 3 новых теста +
advertising-yandex-view.spec.ts обновлён под мок fetchCampaigns.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../components/advertising/CampaignList.vue | 191 ++++++++++++++++++
.../advertising/AdvertisingYandexView.vue | 15 +-
.../advertising-campaign-list.spec.ts | 94 +++++++++
.../Frontend/advertising-yandex-view.spec.ts | 1 +
4 files changed, 298 insertions(+), 3 deletions(-)
create mode 100644 app/resources/js/components/advertising/CampaignList.vue
create mode 100644 app/tests/Frontend/advertising-campaign-list.spec.ts
diff --git a/app/resources/js/components/advertising/CampaignList.vue b/app/resources/js/components/advertising/CampaignList.vue
new file mode 100644
index 00000000..f22fb6a9
--- /dev/null
+++ b/app/resources/js/components/advertising/CampaignList.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
+
+ {{ loadError }}
+
+
+
+
+
Пока нет кампаний
+
+ Новая реклама
+
+
+
+
+
+
+ Новая реклама
+
+
+
+
+
+
+
+ {{ campaign.name }}
+
+
+
+ {{ statusMeta(campaign.status).label }}
+
+
+
+ Бюджет в неделю
+ {{
+ formatMoney(campaign.weekly_budget_rub)
+ }}
+
+
+
+ Запущена
+ {{ formatDate(campaign.launched_at) }}
+
+
+
+
+
+
+ {{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }}
+
+
+
+
+
+
+ Пауза
+
+
+
+
+
+
+
+
+
+ Отчёт
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue
index a7eec093..775945e8 100644
--- a/app/resources/js/views/advertising/AdvertisingYandexView.vue
+++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue
@@ -1,14 +1,23 @@
@@ -30,7 +39,7 @@ const tab = ref('campaigns');
- — скоро —
+
— скоро —
diff --git a/app/tests/Frontend/advertising-campaign-list.spec.ts b/app/tests/Frontend/advertising-campaign-list.spec.ts
new file mode 100644
index 00000000..414d4e86
--- /dev/null
+++ b/app/tests/Frontend/advertising-campaign-list.spec.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+
+const { fetchCampaignsMock } = vi.hoisted(() => ({
+ fetchCampaignsMock: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ fetchCampaigns: fetchCampaignsMock,
+}));
+
+import CampaignList from '../../resources/js/components/advertising/CampaignList.vue';
+
+const vuetify = createVuetify();
+
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)', () => {
+ beforeEach(() => {
+ fetchCampaignsMock.mockReset();
+ });
+
+ it('рендерит карточки кампаний со статус-чипами', async () => {
+ fetchCampaignsMock.mockResolvedValue([
+ {
+ id: 1,
+ name: 'Директора СПб',
+ status: 'running',
+ weekly_budget_rub: '5000.00',
+ audience_days: 30,
+ launched_at: '2026-07-20T10:00:00Z',
+ },
+ {
+ id: 2,
+ name: 'Директора Москвы',
+ status: 'rejected',
+ weekly_budget_rub: '3000.00',
+ audience_days: 14,
+ launched_at: null,
+ },
+ ]);
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ const cards = w.findAll('[data-testid="campaign-card"]');
+ expect(cards).toHaveLength(2);
+ expect(cards[0].text()).toContain('Директора СПб');
+ expect(cards[1].text()).toContain('Директора Москвы');
+
+ const chips = w.findAll('[data-testid="campaign-status-chip"]');
+ expect(chips[0].text()).toContain('Крутится');
+ expect(chips[1].text()).toContain('Отклонено');
+ });
+
+ it('пустой список показывает пустое состояние с кнопкой «Новая реклама», которая эмитит new', async () => {
+ fetchCampaignsMock.mockResolvedValue([]);
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ const empty = w.find('[data-testid="campaign-empty"]');
+ expect(empty.exists()).toBe(true);
+
+ await w.find('[data-testid="campaign-new-btn"]').trigger('click');
+ expect(w.emitted('new')).toBeTruthy();
+ });
+
+ it('клик «Изменить» на карточке эмитит edit с id кампании', async () => {
+ fetchCampaignsMock.mockResolvedValue([
+ {
+ id: 42,
+ name: 'Тестовая кампания',
+ status: 'draft',
+ weekly_budget_rub: '1000.00',
+ audience_days: 7,
+ launched_at: null,
+ },
+ ]);
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await w.find('[data-testid="campaign-edit-btn"]').trigger('click');
+ expect(w.emitted('edit')).toBeTruthy();
+ expect(w.emitted('edit')?.[0]).toEqual([42]);
+ });
+});
diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts
index b95ca315..c8d151fc 100644
--- a/app/tests/Frontend/advertising-yandex-view.spec.ts
+++ b/app/tests/Frontend/advertising-yandex-view.spec.ts
@@ -12,6 +12,7 @@ vi.mock('../../resources/js/api/advertising', () => ({
fetchWallet: vi.fn(() =>
Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
),
+ fetchCampaigns: vi.fn(() => Promise.resolve([])),
}));
import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';
From 480ac8f6be429e5ee2aa2ae6aee19b81e090e7c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 01:28:15 +0300
Subject: [PATCH 032/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=BC=D0=B0?=
=?UTF-8?q?=D1=81=D1=82=D0=B5=D1=80=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA?=
=?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20v-s?=
=?UTF-8?q?tepper=20+=20=D1=88=D0=B0=D0=B3=20=C2=AB=D0=9A=D0=BE=D0=B3?=
=?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B8=D1=80=D1=83?=
=?UTF-8?q?=D0=B5=D0=BC=C2=BB=20=D1=81=20=D0=B6=D0=B8=D0=B2=D1=8B=D0=BC=20?=
=?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=BE=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../components/advertising/CampaignWizard.vue | 232 ++++++++++++++++++
.../advertising/AdvertisingYandexView.vue | 7 +-
.../advertising-campaign-wizard.spec.ts | 116 +++++++++
.../Frontend/advertising-yandex-view.spec.ts | 14 ++
4 files changed, 366 insertions(+), 3 deletions(-)
create mode 100644 app/resources/js/components/advertising/CampaignWizard.vue
create mode 100644 app/tests/Frontend/advertising-campaign-wizard.spec.ts
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
new file mode 100644
index 00000000..8f7157a0
--- /dev/null
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+ {{ draftError }}
+
+
+
+
+
+
Кого рекламируем
+
+ Покажем рекламу тем, кто недавно оставил заявку — выберите, за сколько дней брать контакты.
+
+
+
+ За сколько дней брать свежие сделки
+ {{ form.audience_days }} дн.
+
+
+
+
+
+
+
+ Наберётся ~{{ audienceSize.size }} человек
+
+ {{ audienceError }}
+
+
+
+ {{ audienceSize.hint || 'Маловато для запуска — увеличьте число дней' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Назад
+
+
+ Далее
+
+
+
+
+
+
+
+
diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue
index 775945e8..01309b73 100644
--- a/app/resources/js/views/advertising/AdvertisingYandexView.vue
+++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue
@@ -2,12 +2,13 @@
/**
* «Яндекс Аудитория» — экран рекламного канала (Часть B2, задача B2-2).
* Вкладка «Мои кампании» (B2-4) — CampaignList.vue. Вкладка «Новая реклама» —
- * пока плейсхолдер (мастер создания — следующая задача); api-слой готов
- * в resources/js/api/advertising.ts.
+ * мастер CampaignWizard.vue (B2-5, каркас + шаг 1; шаги 2–4 — заглушки,
+ * наполнят B2-6/7/8); api-слой готов в resources/js/api/advertising.ts.
*/
import { ref } from 'vue';
import AdWalletHeader from '../../components/advertising/AdWalletHeader.vue';
import CampaignList from '../../components/advertising/CampaignList.vue';
+import CampaignWizard from '../../components/advertising/CampaignWizard.vue';
const tab = ref('campaigns');
// id редактируемой кампании — пробрасывается из CampaignList (событие edit),
@@ -42,7 +43,7 @@ function onEdit(id: number): void {
- — скоро —
+
diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
new file mode 100644
index 00000000..55fc5b0d
--- /dev/null
+++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+
+const { createCampaignMock, fetchAudienceSizeMock } = vi.hoisted(() => ({
+ createCampaignMock: vi.fn(),
+ fetchAudienceSizeMock: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ createCampaign: createCampaignMock,
+ fetchAudienceSize: fetchAudienceSizeMock,
+}));
+
+import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue';
+
+const vuetify = createVuetify();
+
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+const DRAFT_CAMPAIGN = {
+ id: 99,
+ name: 'Реклама от 25.07.2026',
+ status: 'draft' as const,
+ weekly_budget_rub: '1000.00',
+ audience_days: 10,
+ launched_at: null,
+};
+
+describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () => {
+ beforeEach(() => {
+ createCampaignMock.mockReset();
+ fetchAudienceSizeMock.mockReset();
+ });
+
+ it('при монтировании создаёт черновик кампании один раз и показывает шаг 1', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(createCampaignMock).toHaveBeenCalledTimes(1);
+ expect(w.find('[data-testid="wizard"]').exists()).toBe(true);
+ expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
+ // Заголовки шагов — строго как в задаче.
+ const text = w.text();
+ expect(text).toContain('Кого рекламируем');
+ expect(text).toContain('Объявления');
+ expect(text).toContain('Бюджет');
+ expect(text).toContain('Проверка и запуск');
+ });
+
+ it('пересчёт счётчика зовёт fetchAudienceSize(id, days); ниже 100 показывает подсказку', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({
+ size: 42,
+ min: 100,
+ enough: false,
+ hint: 'Маловато — увеличьте до 20 дней',
+ });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ // Черновик создан с id 99 и дефолтными 10 днями — счётчик уже посчитан один раз.
+ expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, 10);
+
+ const hint = w.find('[data-testid="audience-hint"]');
+ expect(hint.exists()).toBe(true);
+ expect(hint.text()).toContain('Маловато');
+
+ // Меняем срез дней и зовём пересчёт напрямую (без ожидания реального debounce).
+ (w.vm as unknown as { form: { audience_days: number } }).form.audience_days = 4;
+ await (w.vm as unknown as { refreshAudienceSize: () => Promise }).refreshAudienceSize();
+ await w.vm.$nextTick();
+
+ expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, 4);
+ });
+
+ it('достаточно аудитории — зелёная галочка и результат без подсказки', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ const result = w.find('[data-testid="audience-size-result"]');
+ expect(result.text()).toContain('Наберётся ~120');
+ expect(w.find('[data-testid="audience-hint"]').exists()).toBe(false);
+ });
+
+ it('кнопки «Назад»/«Далее» переключают шаги мастера', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
+
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+ expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true);
+
+ await w.find('[data-testid="wizard-back"]').trigger('click');
+ await w.vm.$nextTick();
+ expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
+ });
+});
diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts
index c8d151fc..1f0c1df7 100644
--- a/app/tests/Frontend/advertising-yandex-view.spec.ts
+++ b/app/tests/Frontend/advertising-yandex-view.spec.ts
@@ -8,11 +8,25 @@ vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
+// CampaignWizard (B2-5) теперь рендерится во вкладке «Новая реклама» и на
+// монтировании создаёт черновик кампании + считает счётчик аудитории —
+// мокаем и то, и другое, чтобы не ходить в сеть.
vi.mock('../../resources/js/api/advertising', () => ({
fetchWallet: vi.fn(() =>
Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
),
fetchCampaigns: vi.fn(() => Promise.resolve([])),
+ createCampaign: vi.fn(() =>
+ Promise.resolve({
+ id: 99,
+ name: 'Реклама от 25.07.2026',
+ status: 'draft',
+ weekly_budget_rub: '1000.00',
+ audience_days: 10,
+ launched_at: null,
+ }),
+ ),
+ fetchAudienceSize: vi.fn(() => Promise.resolve({ size: 120, min: 100, enough: true, hint: null })),
}));
import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';
From 34a57d0fcf3bb5b572df5a195475291b63f89717 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 01:40:15 +0300
Subject: [PATCH 033/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D1=88=D0=B0?=
=?UTF-8?q?=D0=B3=20=C2=AB=D0=9E=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?=
=?UTF-8?q?=D0=B8=D1=8F=C2=BB=20=E2=80=94=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?=
=?UTF-8?q?=20=D0=BA=D1=80=D0=B5=D0=B0=D1=82=D0=B8=D0=B2=D0=B0=20=D1=81=20?=
=?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9=20=D0=B2?=
=?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B8?=
=?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=BE=D0=B9=20?=
=?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/advertising/CampaignWizard.vue | 66 ++++++--
.../components/advertising/CreativeForm.vue | 149 ++++++++++++++++++
.../advertising-campaign-wizard.spec.ts | 8 +-
.../advertising-creative-form.spec.ts | 137 ++++++++++++++++
4 files changed, 348 insertions(+), 12 deletions(-)
create mode 100644 app/resources/js/components/advertising/CreativeForm.vue
create mode 100644 app/tests/Frontend/advertising-creative-form.spec.ts
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
index 8f7157a0..9b938cc3 100644
--- a/app/resources/js/components/advertising/CampaignWizard.vue
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -19,23 +19,22 @@
* в API B1 нет, выдумывать его здесь нельзя).
*/
import { reactive, ref, computed, watch, onMounted } from 'vue';
-import { createCampaign, fetchAudienceSize, type CampaignCreate, type AudienceSize } from '../../api/advertising';
+import {
+ createCampaign,
+ fetchAudienceSize,
+ type CampaignCreate,
+ type AudienceSize,
+ type AdCreative,
+} from '../../api/advertising';
import { extractErrorMessage } from '../../api/client';
-
-/** Объявление кампании — наполнит шаг 2 (B2-6); сейчас только хранится в форме. */
-interface WizardAd {
- title: string;
- text: string;
- href: string;
- title2?: string;
-}
+import CreativeForm from './CreativeForm.vue';
const step = ref(1);
const stepTitles = ['Кого рекламируем', 'Объявления', 'Бюджет', 'Проверка и запуск'];
/** Общая форма мастера — B2-6/7/8 продолжат работать с этим же реактивным объектом. */
-const form = reactive({
+const form = reactive({
name: '',
audience_days: 10,
use_uploaded_list: false,
@@ -105,6 +104,20 @@ function scheduleAudienceRefresh(): void {
watch(() => form.audience_days, scheduleAudienceRefresh);
+/** Объявление добавлено (POST уже прошёл на сервере, см. CreativeForm.vue) — кладём в список формы. */
+function onAdAdded(ad: AdCreative): void {
+ form.ads.push(ad);
+}
+
+/**
+ * Убрать объявление из отображаемого списка — ТОЛЬКО локально, на клиенте.
+ * Эндпоинта удаления объявления на сервере нет (Часть B1) — само объявление
+ * на сервере остаётся, просто перестаёт показываться в мастере.
+ */
+function removeAd(index: number): void {
+ form.ads.splice(index, 1);
+}
+
function goBack(): void {
step.value = Math.max(1, step.value - 1);
}
@@ -191,7 +204,38 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
-
— скоро —
+
Объявления
+
+ Можно добавить несколько объявлений, у каждого своя ссылка.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/resources/js/components/advertising/CreativeForm.vue b/app/resources/js/components/advertising/CreativeForm.vue
new file mode 100644
index 00000000..38588979
--- /dev/null
+++ b/app/resources/js/components/advertising/CreativeForm.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ generalError }}
+
+
+
+ Добавить объявление
+
+
+
diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
index 55fc5b0d..3a4369f8 100644
--- a/app/tests/Frontend/advertising-campaign-wizard.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
@@ -2,14 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
-const { createCampaignMock, fetchAudienceSizeMock } = vi.hoisted(() => ({
+const { createCampaignMock, fetchAudienceSizeMock, addCreativeMock, uploadCreativeImageMock } = vi.hoisted(() => ({
createCampaignMock: vi.fn(),
fetchAudienceSizeMock: vi.fn(),
+ // Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
+ // который смонтирован внутри мастера.
+ addCreativeMock: vi.fn(),
+ uploadCreativeImageMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
createCampaign: createCampaignMock,
fetchAudienceSize: fetchAudienceSizeMock,
+ addCreative: addCreativeMock,
+ uploadCreativeImage: uploadCreativeImageMock,
}));
import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue';
diff --git a/app/tests/Frontend/advertising-creative-form.spec.ts b/app/tests/Frontend/advertising-creative-form.spec.ts
new file mode 100644
index 00000000..83a43e22
--- /dev/null
+++ b/app/tests/Frontend/advertising-creative-form.spec.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount, type VueWrapper } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import { AxiosError } from 'axios';
+
+/**
+ * Тест формы одного объявления (Часть B2, задача B2-6).
+ *
+ * Мокаем только addCreative/uploadCreativeImage (api/advertising) — client.ts
+ * (extractValidationErrors/extractErrorMessage) оставляем настоящим, чтобы
+ * проверить реальную обработку 422 от Laravel (Р35 — фронт не выдумывает
+ * нормативы, только показывает то, что вернул сервер).
+ */
+const { addCreativeMock, uploadCreativeImageMock } = vi.hoisted(() => ({
+ addCreativeMock: vi.fn(),
+ uploadCreativeImageMock: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ addCreative: addCreativeMock,
+ uploadCreativeImage: uploadCreativeImageMock,
+}));
+
+import CreativeForm from '../../resources/js/components/advertising/CreativeForm.vue';
+import type { AdCreative } from '../../resources/js/api/advertising';
+
+const vuetify = createVuetify();
+
+function axErr(status: number, body: unknown): AxiosError {
+ const e = new AxiosError('err');
+ // @ts-expect-error — минимальный мок ответа
+ e.response = { status, data: body, statusText: '', headers: {}, config: {} };
+ return e;
+}
+
+const AD: AdCreative = {
+ id: 5,
+ campaign_id: 42,
+ title: 'Окна со скидкой',
+ text: 'Меняем окна за 3 дня, гарантия 5 лет',
+ href: 'https://example.ru',
+ title2: 'Успейте до конца месяца',
+ moderation_status: 'draft',
+ image_normal_hash: null,
+};
+
+async function fillForm(w: VueWrapper): Promise
{
+ await w.find('[data-testid="creative-title"] input').setValue('Окна со скидкой');
+ await w.find('[data-testid="creative-title2"] input').setValue('Успейте до конца месяца');
+ await w.find('[data-testid="creative-text"] textarea').setValue('Меняем окна за 3 дня, гарантия 5 лет');
+ await w.find('[data-testid="creative-href"] input').setValue('https://example.ru');
+}
+
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+describe('CreativeForm.vue (B2-6 — форма одного объявления)', () => {
+ beforeEach(() => {
+ addCreativeMock.mockReset();
+ uploadCreativeImageMock.mockReset();
+ });
+
+ it('(a) заполненная форма шлёт addCreative и эмиттит added с ответом сервера', async () => {
+ addCreativeMock.mockResolvedValue(AD);
+
+ const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } });
+ await fillForm(w);
+ await w.find('[data-testid="creative-submit"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(addCreativeMock).toHaveBeenCalledWith(42, {
+ title: 'Окна со скидкой',
+ text: 'Меняем окна за 3 дня, гарантия 5 лет',
+ href: 'https://example.ru',
+ title2: 'Успейте до конца месяца',
+ });
+ expect(uploadCreativeImageMock).not.toHaveBeenCalled();
+ expect(w.emitted('added')).toBeTruthy();
+ expect(w.emitted('added')![0]).toEqual([AD]);
+
+ // Поля очищены после успешного добавления.
+ expect((w.find('[data-testid="creative-title"] input').element as HTMLInputElement).value).toBe('');
+ });
+
+ it('(b) сервер вернул 422 по title — сообщение показано под полем, added не эмиттится', async () => {
+ addCreativeMock.mockRejectedValue(axErr(422, { errors: { title: ['Слишком длинный заголовок'] } }));
+
+ const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } });
+ await fillForm(w);
+ await w.find('[data-testid="creative-submit"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.text()).toContain('Слишком длинный заголовок');
+ expect(w.emitted('added')).toBeFalsy();
+ expect(w.find('[data-testid="creative-error"]').exists()).toBe(false);
+ });
+
+ it('(c) с выбранным файлом после addCreative вызывается uploadCreativeImage(campaignId, ad.id, file)', async () => {
+ addCreativeMock.mockResolvedValue(AD);
+ uploadCreativeImageMock.mockResolvedValue({ hash: 'abc123' });
+
+ const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } });
+ await fillForm(w);
+
+ const file = new File(['x'], 'pic.png', { type: 'image/png' });
+ // v-file-input в jsdom нельзя надёжно наполнить через DOM-событие смены файла —
+ // используем defineExpose (тот же приём, что и в CampaignWizard.vue).
+ (w.vm as unknown as { file: File | null }).file = file;
+ await w.vm.$nextTick();
+
+ await w.find('[data-testid="creative-submit"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(addCreativeMock).toHaveBeenCalledTimes(1);
+ expect(uploadCreativeImageMock).toHaveBeenCalledWith(42, AD.id, file);
+ expect(w.emitted('added')).toBeTruthy();
+ });
+
+ it('общая ошибка (не 422) показывается в v-alert, а не под полем', async () => {
+ addCreativeMock.mockRejectedValue(axErr(500, { message: 'Внутренняя ошибка сервера.' }));
+
+ const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } });
+ await fillForm(w);
+ await w.find('[data-testid="creative-submit"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ const alert = w.find('[data-testid="creative-error"]');
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toContain('Внутренняя ошибка сервера.');
+ expect(w.emitted('added')).toBeFalsy();
+ });
+});
From 11bc282729b896458169d2ff10907b32bdc85dbe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 01:46:43 +0300
Subject: [PATCH 034/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D1=88=D0=B0?=
=?UTF-8?q?=D0=B3=20=C2=AB=D0=91=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=C2=BB=20(?=
=?UTF-8?q?=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9/=D0=B4?=
=?UTF-8?q?=D0=BD=D0=B5=D0=B2=D0=BD=D0=BE=D0=B9,=20=D1=82=D0=BE=D0=BB?=
=?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D0=B0=20?=
=?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BD=D0=BE=D0=B7?=
=?UTF-8?q?=D0=BE=D0=B2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../components/advertising/CampaignWizard.vue | 80 ++++++++++++++-
.../advertising-campaign-wizard.spec.ts | 99 +++++++++++++++++--
2 files changed, 168 insertions(+), 11 deletions(-)
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
index 9b938cc3..4d5ec700 100644
--- a/app/resources/js/components/advertising/CampaignWizard.vue
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -21,6 +21,7 @@
import { reactive, ref, computed, watch, onMounted } from 'vue';
import {
createCampaign,
+ patchCampaign,
fetchAudienceSize,
type CampaignCreate,
type AudienceSize,
@@ -118,11 +119,50 @@ function removeAd(index: number): void {
form.ads.splice(index, 1);
}
+const budgetError = ref(null);
+const budgetSaving = ref(false);
+
function goBack(): void {
step.value = Math.max(1, step.value - 1);
}
-function goNext(): void {
+/**
+ * Уходя с шага 3 «Бюджет» — сохраняем черновик (Р37: фронт денег не считает,
+ * передаёт строки как ввёл пользователь). Пустой недельный бюджет блокирует
+ * переход; ошибка PATCH — тоже блокирует, введённое не теряется (форма не чистится).
+ */
+async function saveBudgetStep(): Promise {
+ if (!form.weekly_budget_rub || form.weekly_budget_rub.trim() === '') {
+ budgetError.value = 'Укажите бюджет в неделю.';
+ return false;
+ }
+ if (draftId.value === null) {
+ budgetError.value = 'Черновик кампании ещё не создан.';
+ return false;
+ }
+ budgetError.value = null;
+ budgetSaving.value = true;
+ try {
+ await patchCampaign(draftId.value, {
+ weekly_budget_rub: form.weekly_budget_rub,
+ daily_budget_rub: form.daily_budget_rub || null,
+ audience_days: form.audience_days,
+ use_uploaded_list: form.use_uploaded_list,
+ });
+ return true;
+ } catch (e) {
+ budgetError.value = extractErrorMessage(e, 'Не удалось сохранить бюджет.');
+ return false;
+ } finally {
+ budgetSaving.value = false;
+ }
+}
+
+async function goNext(): Promise {
+ if (step.value === 3) {
+ const saved = await saveBudgetStep();
+ if (!saved) return;
+ }
step.value = Math.min(stepTitles.length, step.value + 1);
}
@@ -138,7 +178,7 @@ onMounted(() => {
void createDraft();
});
-defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
+defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError });
@@ -239,7 +279,40 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
-
— скоро —
+
Бюджет
+
+ Столько максимум спишется за неделю. Платите только за переходы по объявлению.
+
+
+
+ Ваша аудитория: ~{{ audienceSize.size }} человек
+
+
+
+
+
+
+
+ {{ budgetError }}
+
@@ -257,6 +330,7 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
color="primary"
variant="flat"
:disabled="step === stepTitles.length"
+ :loading="budgetSaving"
data-testid="wizard-next"
@click="goNext"
>
diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
index 3a4369f8..6eca7a52 100644
--- a/app/tests/Frontend/advertising-campaign-wizard.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
@@ -2,18 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
-const { createCampaignMock, fetchAudienceSizeMock, addCreativeMock, uploadCreativeImageMock } = vi.hoisted(() => ({
- createCampaignMock: vi.fn(),
- fetchAudienceSizeMock: vi.fn(),
- // Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
- // который смонтирован внутри мастера.
- addCreativeMock: vi.fn(),
- uploadCreativeImageMock: vi.fn(),
-}));
+const { createCampaignMock, fetchAudienceSizeMock, patchCampaignMock, addCreativeMock, uploadCreativeImageMock } =
+ vi.hoisted(() => ({
+ createCampaignMock: vi.fn(),
+ fetchAudienceSizeMock: vi.fn(),
+ patchCampaignMock: vi.fn(),
+ // Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
+ // который смонтирован внутри мастера.
+ addCreativeMock: vi.fn(),
+ uploadCreativeImageMock: vi.fn(),
+ }));
vi.mock('../../resources/js/api/advertising', () => ({
createCampaign: createCampaignMock,
fetchAudienceSize: fetchAudienceSizeMock,
+ patchCampaign: patchCampaignMock,
addCreative: addCreativeMock,
uploadCreativeImage: uploadCreativeImageMock,
}));
@@ -39,6 +42,7 @@ describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () =
beforeEach(() => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
+ patchCampaignMock.mockReset();
});
it('при монтировании создаёт черновик кампании один раз и показывает шаг 1', async () => {
@@ -120,3 +124,82 @@ describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () =
expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
});
});
+
+describe('CampaignWizard.vue (B2-7 — шаг 3 «Бюджет»)', () => {
+ beforeEach(() => {
+ createCampaignMock.mockReset();
+ fetchAudienceSizeMock.mockReset();
+ patchCampaignMock.mockReset();
+ });
+
+ /** Переходим на шаг 3 кликами «Далее» — так же, как реальный пользователь. */
+ async function gotoStep3(w: ReturnType): Promise {
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+ expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true);
+ }
+
+ it('ввод недельного бюджета и «Далее» на шаге 3 сохраняет черновик через patchCampaign', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+ patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, weekly_budget_rub: '2500.00' });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await gotoStep3(w);
+
+ const weeklyInput = w.find('[data-testid="budget-weekly"] input');
+ await weeklyInput.setValue('2500');
+ await w.vm.$nextTick();
+
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(patchCampaignMock).toHaveBeenCalledWith(99, expect.objectContaining({ weekly_budget_rub: '2500' }));
+ // Переход на шаг 4 подтверждает, что сохранение не упало.
+ expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true);
+ });
+
+ it('пустой недельный бюджет блокирует «Далее» — patchCampaign не зовётся', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await gotoStep3(w);
+
+ const weeklyInput = w.find('[data-testid="budget-weekly"] input');
+ await weeklyInput.setValue('');
+ await w.vm.$nextTick();
+
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(patchCampaignMock).not.toHaveBeenCalled();
+ expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true);
+ expect(w.find('[data-testid="wizard-budget-error"]').exists()).toBe(true);
+ });
+
+ it('budget-audience-note показывает размер аудитории из счётчика мастера', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 340, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await gotoStep3(w);
+
+ const note = w.find('[data-testid="budget-audience-note"]');
+ expect(note.exists()).toBe(true);
+ expect(note.text()).toContain('340');
+ });
+});
From ed515f516f6d9456917a57eed05164e5ae8e28dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 02:02:38 +0300
Subject: [PATCH 035/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D1=88=D0=B0?=
=?UTF-8?q?=D0=B3=20=C2=AB=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=
=?UTF-8?q?=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=C2=BB=20?=
=?UTF-8?q?=E2=80=94=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0,=20=D1=81?=
=?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D0=B5,=20=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BF=D1=83=D1=81=D0=BA=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/advertising/CampaignWizard.vue | 122 ++++++++++++++-
.../advertising/AdvertisingYandexView.vue | 33 +++-
.../advertising-campaign-wizard.spec.ts | 144 ++++++++++++++++--
.../Frontend/advertising-yandex-view.spec.ts | 34 ++++-
4 files changed, 312 insertions(+), 21 deletions(-)
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
index 4d5ec700..bf63d6d6 100644
--- a/app/resources/js/components/advertising/CampaignWizard.vue
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -3,10 +3,13 @@
* Мастер запуска кампании «Яндекс Аудитория» (Часть B2, задача B2-5).
*
* Каркас — v-stepper на 4 шага (Р29/Р42 — макет свой, визуальный язык строго
- * по дизайн-системе портала: Vuetify 3, токены темы, mdi-иконки). Сейчас
- * наполнен только шаг 1 «Кого рекламируем» (Р7, Р16, Р17, Р21–Р23); шаги
- * 2–4 — пустые заглушки, их наполнят задачи B2-6 (Объявления), B2-7 (Бюджет)
- * и B2-8 (Проверка и запуск).
+ * по дизайн-системе портала: Vuetify 3, токены темы, mdi-иконки). Все 4 шага
+ * наполнены: 1 «Кого рекламируем» (Р7, Р16, Р17, Р21–Р23), 2 «Объявления»
+ * (B2-6), 3 «Бюджет» (B2-7), 4 «Проверка и запуск» (B2-8) — короткая
+ * сводка + галочка-согласие (Р24, только про загруженный клиентом список
+ * номеров) + запуск через `launchCampaign`; успех эмитит `launched`,
+ * родитель (AdvertisingYandexView.vue) переключает вкладку и перезагружает
+ * список кампаний.
*
* ЧЕРНОВИК: при монтировании мастера сразу создаём черновик кампании
* (POST /api/advertising/campaigns, api/advertising.ts::createCampaign) —
@@ -23,6 +26,7 @@ import {
createCampaign,
patchCampaign,
fetchAudienceSize,
+ launchCampaign,
type CampaignCreate,
type AudienceSize,
type AdCreative,
@@ -30,6 +34,10 @@ import {
import { extractErrorMessage } from '../../api/client';
import CreativeForm from './CreativeForm.vue';
+const emit = defineEmits<{
+ launched: [];
+}>();
+
const step = ref(1);
const stepTitles = ['Кого рекламируем', 'Объявления', 'Бюджет', 'Проверка и запуск'];
@@ -166,6 +174,40 @@ async function goNext(): Promise {
step.value = Math.min(stepTitles.length, step.value + 1);
}
+/**
+ * Шаг 4 «Проверка и запуск» (B2-8). Согласие (Р24) требуется, ТОЛЬКО когда
+ * клиент использует свой загруженный список номеров — наши сделки его не
+ * касаются, поэтому галочку не показываем и не требуем, если
+ * `form.use_uploaded_list === false`.
+ */
+const consent = ref(false);
+const launching = ref(false);
+const launchError = ref(null);
+
+const needsConsent = computed(() => form.use_uploaded_list);
+const launchDisabled = computed(() => !canLaunch.value || (needsConsent.value && !consent.value));
+
+/** Подсказка рядом с заблокированной кнопкой запуска — объясняет, чего не хватает. */
+const launchHint = computed(() => {
+ if (!canLaunch.value) return 'Наберите ещё немного аудитории — сейчас маловато для запуска.';
+ if (needsConsent.value && !consent.value) return 'Поставьте галочку согласия, чтобы запустить рекламу.';
+ return null;
+});
+
+async function launchNow(): Promise {
+ if (draftId.value === null || launchDisabled.value) return;
+ launchError.value = null;
+ launching.value = true;
+ try {
+ await launchCampaign(draftId.value);
+ emit('launched');
+ } catch (e) {
+ launchError.value = extractErrorMessage(e, 'Не удалось запустить рекламу.');
+ } finally {
+ launching.value = false;
+ }
+}
+
/**
* VStepper's `#item` slot типизирован как `StepperItem = string | Record`
* (Vuetify 3.12) — читаем `value` через явный каст, чтобы не городить `as any` в шаблоне.
@@ -178,7 +220,7 @@ onMounted(() => {
void createDraft();
});
-defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError });
+defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError, launchError });
@@ -316,7 +358,75 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budg
-
— скоро —
+
Проверка и запуск
+
+ Проверьте данные — после запуска реклама уйдёт на модерацию в Яндекс.
+
+
+
+
+ Аудитория
+ ~{{ audienceSize?.size ?? 0 }} человек
+
+
+ Объявлений
+ {{ form.ads.length }}
+
+
+ Бюджет в неделю
+ {{ form.weekly_budget_rub }} ₽
+
+
+ Бюджет в день
+ {{ form.daily_budget_rub }} ₽
+
+
+
+
+
+
+ Я подтверждаю, что имею право использовать загруженные мной номера для рекламы (согласие
+ их владельцев получено)
+
+
+
+
+
+ {{ launchError }}
+
+
+
+ Запустить рекламу
+
+
+
+ {{ launchHint }}
+
diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue
index 01309b73..bcd8df53 100644
--- a/app/resources/js/views/advertising/AdvertisingYandexView.vue
+++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue
@@ -2,8 +2,12 @@
/**
* «Яндекс Аудитория» — экран рекламного канала (Часть B2, задача B2-2).
* Вкладка «Мои кампании» (B2-4) — CampaignList.vue. Вкладка «Новая реклама» —
- * мастер CampaignWizard.vue (B2-5, каркас + шаг 1; шаги 2–4 — заглушки,
- * наполнят B2-6/7/8); api-слой готов в resources/js/api/advertising.ts.
+ * мастер CampaignWizard.vue (B2-5…B2-8, все 4 шага наполнены); api-слой готов
+ * в resources/js/api/advertising.ts.
+ *
+ * На `launched` (шаг 4 мастера, успешный запуск) — переключаемся на вкладку
+ * «Мои кампании», перезагружаем список (через exposed `loadCampaigns` у
+ * CampaignList.vue) и показываем короткий тост.
*/
import { ref } from 'vue';
import AdWalletHeader from '../../components/advertising/AdWalletHeader.vue';
@@ -15,10 +19,21 @@ const tab = ref('campaigns');
// понадобится мастеру редактирования в B2-9; пока только храним и переключаем вкладку.
const editCampaignId = ref(null);
+const campaignListRef = ref | null>(null);
+const launchedToastOpen = ref(false);
+
function onEdit(id: number): void {
editCampaignId.value = id;
tab.value = 'new';
}
+
+function onLaunched(): void {
+ tab.value = 'campaigns';
+ void campaignListRef.value?.loadCampaigns();
+ launchedToastOpen.value = true;
+}
+
+defineExpose({ tab });
@@ -40,11 +55,21 @@ function onEdit(id: number): void {
-
+
-
+
+
+
+ Реклама отправлена на проверку Яндексу
+
diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
index 6eca7a52..d2075ec7 100644
--- a/app/tests/Frontend/advertising-campaign-wizard.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
@@ -2,16 +2,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
-const { createCampaignMock, fetchAudienceSizeMock, patchCampaignMock, addCreativeMock, uploadCreativeImageMock } =
- vi.hoisted(() => ({
- createCampaignMock: vi.fn(),
- fetchAudienceSizeMock: vi.fn(),
- patchCampaignMock: vi.fn(),
- // Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
- // который смонтирован внутри мастера.
- addCreativeMock: vi.fn(),
- uploadCreativeImageMock: vi.fn(),
- }));
+const {
+ createCampaignMock,
+ fetchAudienceSizeMock,
+ patchCampaignMock,
+ addCreativeMock,
+ uploadCreativeImageMock,
+ launchCampaignMock,
+} = vi.hoisted(() => ({
+ createCampaignMock: vi.fn(),
+ fetchAudienceSizeMock: vi.fn(),
+ patchCampaignMock: vi.fn(),
+ // Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
+ // который смонтирован внутри мастера.
+ addCreativeMock: vi.fn(),
+ uploadCreativeImageMock: vi.fn(),
+ launchCampaignMock: vi.fn(),
+}));
vi.mock('../../resources/js/api/advertising', () => ({
createCampaign: createCampaignMock,
@@ -19,8 +26,20 @@ vi.mock('../../resources/js/api/advertising', () => ({
patchCampaign: patchCampaignMock,
addCreative: addCreativeMock,
uploadCreativeImage: uploadCreativeImageMock,
+ launchCampaign: launchCampaignMock,
}));
+/**
+ * Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
+ * с нужным `response.data.message` — см. AdminBillingViewActions.spec.ts.
+ */
+function makeAxiosError(message: string, status = 422): unknown {
+ return Object.assign(new Error(message), {
+ isAxiosError: true,
+ response: { status, data: { message } },
+ });
+}
+
import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue';
const vuetify = createVuetify();
@@ -130,6 +149,7 @@ describe('CampaignWizard.vue (B2-7 — шаг 3 «Бюджет»)', () => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
+ launchCampaignMock.mockReset();
});
/** Переходим на шаг 3 кликами «Далее» — так же, как реальный пользователь. */
@@ -203,3 +223,107 @@ describe('CampaignWizard.vue (B2-7 — шаг 3 «Бюджет»)', () => {
expect(note.text()).toContain('340');
});
});
+
+describe('CampaignWizard.vue (B2-8 — шаг 4 «Проверка и запуск»)', () => {
+ beforeEach(() => {
+ createCampaignMock.mockReset();
+ fetchAudienceSizeMock.mockReset();
+ patchCampaignMock.mockReset();
+ launchCampaignMock.mockReset();
+ });
+
+ /** Доходим до шага 4 так же, как реальный пользователь — тремя кликами «Далее». */
+ async function gotoStep4(w: ReturnType): Promise {
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+ expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true);
+ }
+
+ it('(a) canLaunch=true, use_uploaded_list=false — кнопка активна, запуск шлёт launchCampaign и эмитит launched', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+ patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, weekly_budget_rub: '1000.00' });
+ launchCampaignMock.mockResolvedValue({ status: 'pending_moderation' });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await gotoStep4(w);
+
+ expect(w.find('[data-testid="launch-consent"]').exists()).toBe(false);
+
+ const launchBtn = w.find('[data-testid="launch-btn"]');
+ expect(launchBtn.attributes('disabled')).toBeUndefined();
+
+ await launchBtn.trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(launchCampaignMock).toHaveBeenCalledWith(99);
+ expect(w.emitted('launched')).toBeTruthy();
+ });
+
+ it('(b) use_uploaded_list=true без согласия блокирует запуск; после галочки — доступен', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+ patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, weekly_budget_rub: '1000.00' });
+ launchCampaignMock.mockResolvedValue({ status: 'pending_moderation' });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ // Включаем «мой список» — как на шаге 1 реальный пользователь щёлкнул бы переключатель.
+ (w.vm as unknown as { form: { use_uploaded_list: boolean } }).form.use_uploaded_list = true;
+ await w.vm.$nextTick();
+
+ await gotoStep4(w);
+
+ expect(w.find('[data-testid="launch-consent"]').exists()).toBe(true);
+
+ let launchBtn = w.find('[data-testid="launch-btn"]');
+ expect(launchBtn.attributes('disabled')).toBeDefined();
+
+ const consentInput = w.find('[data-testid="launch-consent"] input[type="checkbox"]');
+ await consentInput.setValue(true);
+ await w.vm.$nextTick();
+
+ launchBtn = w.find('[data-testid="launch-btn"]');
+ expect(launchBtn.attributes('disabled')).toBeUndefined();
+
+ await launchBtn.trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(launchCampaignMock).toHaveBeenCalledWith(99);
+ expect(w.emitted('launched')).toBeTruthy();
+ });
+
+ it('(c) launchCampaign отклоняется с 422 «мало аудитории» — виден launch-error с этим текстом', async () => {
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+ patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, weekly_budget_rub: '1000.00' });
+ launchCampaignMock.mockRejectedValue(makeAxiosError('Аудитория слишком мала для запуска.', 422));
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ await gotoStep4(w);
+
+ await w.find('[data-testid="launch-btn"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ const errorEl = w.find('[data-testid="launch-error"]');
+ expect(errorEl.exists()).toBe(true);
+ expect(errorEl.text()).toContain('Аудитория слишком мала для запуска.');
+ expect(w.emitted('launched')).toBeFalsy();
+ });
+});
diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts
index 1f0c1df7..1f654086 100644
--- a/app/tests/Frontend/advertising-yandex-view.spec.ts
+++ b/app/tests/Frontend/advertising-yandex-view.spec.ts
@@ -8,6 +8,10 @@ vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
+const { fetchCampaignsMock } = vi.hoisted(() => ({
+ fetchCampaignsMock: vi.fn(() => Promise.resolve([])),
+}));
+
// CampaignWizard (B2-5) теперь рендерится во вкладке «Новая реклама» и на
// монтировании создаёт черновик кампании + считает счётчик аудитории —
// мокаем и то, и другое, чтобы не ходить в сеть.
@@ -15,7 +19,7 @@ vi.mock('../../resources/js/api/advertising', () => ({
fetchWallet: vi.fn(() =>
Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
),
- fetchCampaigns: vi.fn(() => Promise.resolve([])),
+ fetchCampaigns: fetchCampaignsMock,
createCampaign: vi.fn(() =>
Promise.resolve({
id: 99,
@@ -27,12 +31,17 @@ vi.mock('../../resources/js/api/advertising', () => ({
}),
),
fetchAudienceSize: vi.fn(() => Promise.resolve({ size: 120, min: 100, enough: true, hint: null })),
+ launchCampaign: vi.fn(() => Promise.resolve({ status: 'pending_moderation' })),
}));
import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';
const vuetify = createVuetify();
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
describe('AdvertisingYandexView.vue', () => {
const factory = () =>
mount(AdvertisingYandexView, {
@@ -50,4 +59,27 @@ describe('AdvertisingYandexView.vue', () => {
expect(text).toContain('Мои кампании');
expect(text).toContain('Новая реклама');
});
+
+ it('(d) на событие launched от мастера переключает вкладку на «Мои кампании» и перезагружает список', async () => {
+ fetchCampaignsMock.mockClear();
+ const w = factory();
+ await flush();
+ await w.vm.$nextTick();
+
+ const callsBeforeSwitch = fetchCampaignsMock.mock.calls.length;
+
+ // Переходим во вкладку «Новая реклама» — там же примонтируется мастер (v-tabs-window ленивый).
+ await w.find('[data-testid="tab-new"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ const wizard = w.findComponent({ name: 'CampaignWizard' });
+ expect(wizard.exists()).toBe(true);
+ wizard.vm.$emit('launched');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect((w.vm as unknown as { tab: string }).tab).toBe('campaigns');
+ expect(fetchCampaignsMock.mock.calls.length).toBeGreaterThan(callsBeforeSwitch);
+ });
});
From b19579937e1116e19c32c706a91ab440de2030cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 02:17:43 +0300
Subject: [PATCH 036/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B8=20=D0=BD=D0=B0=20=D1=85=D0=BE=D0=B4=D1=83=20(?=
=?UTF-8?q?=D0=A030)=20+=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?=
=?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=
=?UTF-8?q?=20B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/resources/js/api/advertising.ts | 7 ++
.../components/advertising/CampaignWizard.vue | 65 +++++++++++++-
.../advertising/AdvertisingYandexView.vue | 32 +++++--
.../advertising-campaign-wizard.spec.ts | 85 +++++++++++++++++++
.../Frontend/advertising-yandex-view.spec.ts | 71 +++++++++++++++-
5 files changed, 253 insertions(+), 7 deletions(-)
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
index aa0bee83..723147cd 100644
--- a/app/resources/js/api/advertising.ts
+++ b/app/resources/js/api/advertising.ts
@@ -27,6 +27,13 @@ export interface Campaign {
weekly_budget_rub: string;
audience_days: number;
launched_at: string | null;
+ /**
+ * Приходят и в POST/PATCH-ответе, и в GET .../{id} (detail, controller::show
+ * отдаёт модель целиком) — но НЕ в списке GET /campaigns (index явно
+ * выбирает узкий набор колонок), поэтому опциональны.
+ */
+ use_uploaded_list?: boolean;
+ daily_budget_rub?: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
index bf63d6d6..9bf8345d 100644
--- a/app/resources/js/components/advertising/CampaignWizard.vue
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -20,11 +20,22 @@
* НЕ чистятся и НЕ переиспользуются — они остаются в статусе draft. Чистка/
* переиспользование черновиков — следующий инкремент (эндпоинта удаления
* в API B1 нет, выдумывать его здесь нельзя).
+ *
+ * РЕЖИМ ПРАВКИ (B2-9, Р30): опциональный prop `campaignId`. Если задан — при
+ * монтировании грузим существующую кампанию (`fetchCampaign`) и префиллим
+ * форму вместо создания нового черновика (`createCampaign` НЕ зовём). Смена
+ * `campaignId` без размонтирования мастера НЕ поддерживается этим компонентом
+ * (только `onMounted`) — контейнер (AdvertisingYandexView.vue) обязан
+ * ремаунтить мастер через `:key`, когда меняет режим (создание/правка другой
+ * кампании). Кампанию можно менять «на ходу», не останавливая: аудитория/
+ * список подхватятся ночным replace-джобом только на следующий день, бюджет —
+ * сразу (уже так работает `saveBudgetStep` → `patchCampaign`, без изменений).
*/
import { reactive, ref, computed, watch, onMounted } from 'vue';
import {
createCampaign,
patchCampaign,
+ fetchCampaign,
fetchAudienceSize,
launchCampaign,
type CampaignCreate,
@@ -34,10 +45,21 @@ import {
import { extractErrorMessage } from '../../api/client';
import CreativeForm from './CreativeForm.vue';
+const props = defineProps<{
+ /** Id кампании для режима правки (B2-9). Отсутствует/null — режим создания. */
+ campaignId?: number | null;
+}>();
+
const emit = defineEmits<{
launched: [];
}>();
+/** Режим правки — грузим существующую кампанию вместо создания черновика. */
+const isEditMode = computed(() => props.campaignId != null);
+
+/** Заголовок мастера — единственное видимое отличие режима правки от создания (кроме подсказки Р30). */
+const wizardTitle = computed(() => (isEditMode.value ? 'Изменить кампанию' : 'Новая реклама'));
+
const step = ref(1);
const stepTitles = ['Кого рекламируем', 'Объявления', 'Бюджет', 'Проверка и запуск'];
@@ -103,6 +125,28 @@ async function createDraft(): Promise {
}
}
+/**
+ * Режим правки (B2-9, Р30) — грузим существующую кампанию вместо создания
+ * черновика; сохранения дальше идут как обычно через `patchCampaign`
+ * (`draftId` уже указывает на реальную, не черновую, кампанию).
+ */
+async function loadForEdit(id: number): Promise {
+ draftError.value = null;
+ try {
+ const detail = await fetchCampaign(id);
+ form.name = detail.campaign.name;
+ form.audience_days = detail.campaign.audience_days;
+ form.use_uploaded_list = detail.campaign.use_uploaded_list ?? false;
+ form.weekly_budget_rub = detail.campaign.weekly_budget_rub;
+ form.daily_budget_rub = detail.campaign.daily_budget_rub ?? null;
+ form.ads = detail.ads ?? [];
+ draftId.value = detail.campaign.id;
+ await refreshAudienceSize();
+ } catch (e) {
+ draftError.value = extractErrorMessage(e, 'Не удалось загрузить кампанию для правки.');
+ }
+}
+
/** Ползунок дней меняется часто — считаем счётчик не на каждый пиксель, а с задержкой ~400мс. */
function scheduleAudienceRefresh(): void {
if (debounceTimer) clearTimeout(debounceTimer);
@@ -217,7 +261,11 @@ function stepValue(item: unknown): number {
}
onMounted(() => {
- void createDraft();
+ if (props.campaignId != null) {
+ void loadForEdit(props.campaignId);
+ } else {
+ void createDraft();
+ }
});
defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError, launchError });
@@ -225,6 +273,8 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budg
+
{{ wizardTitle }}
+
{{ draftError }}
@@ -237,6 +287,19 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budg
Покажем рекламу тем, кто недавно оставил заявку — выберите, за сколько дней брать контакты.
+
+ Изменения в списке подхватятся завтра — Яндекс собирает аудиторию раз в сутки. Бюджет
+ применится сразу.
+
+
За сколько дней брать свежие сделки
{{ form.audience_days }} дн.
diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue
index bcd8df53..81b3b900 100644
--- a/app/resources/js/views/advertising/AdvertisingYandexView.vue
+++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue
@@ -8,6 +8,14 @@
* На `launched` (шаг 4 мастера, успешный запуск) — переключаемся на вкладку
* «Мои кампании», перезагружаем список (через exposed `loadCampaigns` у
* CampaignList.vue) и показываем короткий тост.
+ *
+ * ПРАВКА НА ХОДУ (B2-9, Р30): «Изменить» на карточке (CampaignList → `edit`)
+ * передаёт id в `editCampaignId`, мастер получает его как prop `campaign-id`
+ * и грузит существующую кампанию вместо создания черновика. `wizardKey`
+ * бампается на каждый вход в мастер (и с id, и без) — `CampaignWizard`
+ * читает `campaignId` только в `onMounted`, поэтому единственный надёжный
+ * способ отдать ему новые данные — заставить Vue пересоздать компонент
+ * через смену `:key`.
*/
import { ref } from 'vue';
import AdWalletHeader from '../../components/advertising/AdWalletHeader.vue';
@@ -15,20 +23,34 @@ import CampaignList from '../../components/advertising/CampaignList.vue';
import CampaignWizard from '../../components/advertising/CampaignWizard.vue';
const tab = ref('campaigns');
-// id редактируемой кампании — пробрасывается из CampaignList (событие edit),
-// понадобится мастеру редактирования в B2-9; пока только храним и переключаем вкладку.
const editCampaignId = ref(null);
+const wizardKey = ref(0);
const campaignListRef = ref | null>(null);
const launchedToastOpen = ref(false);
+/** «Изменить» на карточке — открыть мастер в режиме правки этой кампании. */
function onEdit(id: number): void {
editCampaignId.value = id;
+ wizardKey.value += 1;
+ tab.value = 'new';
+}
+
+/**
+ * Вход в мастер СОЗДАНИЯ — «+ Новая реклама» (кнопка CampaignList) или клик
+ * по вкладке «Новая реклама» напрямую. Всегда чистый мастер, даже если до
+ * этого был открыт в режиме правки.
+ */
+function onNewCampaignTab(): void {
+ editCampaignId.value = null;
+ wizardKey.value += 1;
tab.value = 'new';
}
function onLaunched(): void {
tab.value = 'campaigns';
+ editCampaignId.value = null;
+ wizardKey.value += 1;
void campaignListRef.value?.loadCampaigns();
launchedToastOpen.value = true;
}
@@ -50,15 +72,15 @@ defineExpose({ tab });
Мои кампании
- Новая реклама
+ Новая реклама
-
+
-
+
diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
index d2075ec7..a55afc1b 100644
--- a/app/tests/Frontend/advertising-campaign-wizard.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
@@ -6,6 +6,7 @@ const {
createCampaignMock,
fetchAudienceSizeMock,
patchCampaignMock,
+ fetchCampaignMock,
addCreativeMock,
uploadCreativeImageMock,
launchCampaignMock,
@@ -13,6 +14,8 @@ const {
createCampaignMock: vi.fn(),
fetchAudienceSizeMock: vi.fn(),
patchCampaignMock: vi.fn(),
+ // B2-9 — правка на ходу: fetchCampaign(id) грузит существующую кампанию.
+ fetchCampaignMock: vi.fn(),
// Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
// который смонтирован внутри мастера.
addCreativeMock: vi.fn(),
@@ -24,6 +27,7 @@ vi.mock('../../resources/js/api/advertising', () => ({
createCampaign: createCampaignMock,
fetchAudienceSize: fetchAudienceSizeMock,
patchCampaign: patchCampaignMock,
+ fetchCampaign: fetchCampaignMock,
addCreative: addCreativeMock,
uploadCreativeImage: uploadCreativeImageMock,
launchCampaign: launchCampaignMock,
@@ -327,3 +331,84 @@ describe('CampaignWizard.vue (B2-8 — шаг 4 «Проверка и запус
expect(w.emitted('launched')).toBeFalsy();
});
});
+
+describe('CampaignWizard.vue (B2-9 — правка на ходу, Р30)', () => {
+ beforeEach(() => {
+ createCampaignMock.mockReset();
+ fetchAudienceSizeMock.mockReset();
+ patchCampaignMock.mockReset();
+ fetchCampaignMock.mockReset();
+ });
+
+ const EXISTING_CAMPAIGN = {
+ id: 42,
+ name: 'Реклама от 01.07.2026',
+ status: 'running' as const,
+ weekly_budget_rub: '3500.00',
+ audience_days: 20,
+ launched_at: '2026-07-01T10:00:00Z',
+ use_uploaded_list: true,
+ daily_budget_rub: '600.00',
+ };
+
+ it('(a) с campaignId=42 грузит fetchCampaign, НЕ создаёт черновик, префиллит поля, виден wizard-edit-hint', async () => {
+ fetchCampaignMock.mockResolvedValue({
+ campaign: EXISTING_CAMPAIGN,
+ ads: [],
+ spent_rub: '120.00',
+ });
+ fetchAudienceSizeMock.mockResolvedValue({ size: 340, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, {
+ props: { campaignId: 42 },
+ global: { plugins: [vuetify] },
+ });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(fetchCampaignMock).toHaveBeenCalledWith(42);
+ expect(createCampaignMock).not.toHaveBeenCalled();
+ expect((w.vm as unknown as { draftId: number | null }).draftId).toBe(42);
+
+ // Подсказка Р30 видна в режиме правки на шаге 1.
+ const hint = w.find('[data-testid="wizard-edit-hint"]');
+ expect(hint.exists()).toBe(true);
+ expect(hint.text()).toContain('Изменения в списке подхватятся завтра');
+
+ // Заголовок мастера — «Изменить кампанию», не «Новая реклама».
+ expect(w.text()).toContain('Изменить кампанию');
+
+ // Идём на шаг 3 — бюджет префиллен из ответа fetchCampaign.
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+
+ const weeklyInput = w.find('[data-testid="budget-weekly"] input');
+ expect(Number((weeklyInput.element as HTMLInputElement).value)).toBe(3500);
+
+ const dailyInput = w.find('[data-testid="budget-daily"] input');
+ expect(Number((dailyInput.element as HTMLInputElement).value)).toBe(600);
+ });
+
+ it('(b) без campaignId — прежнее поведение: createCampaign зовётся, wizard-edit-hint не виден', async () => {
+ createCampaignMock.mockResolvedValue({
+ id: 99,
+ name: 'Реклама от 25.07.2026',
+ status: 'draft' as const,
+ weekly_budget_rub: '1000.00',
+ audience_days: 10,
+ launched_at: null,
+ });
+ fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(createCampaignMock).toHaveBeenCalledTimes(1);
+ expect(fetchCampaignMock).not.toHaveBeenCalled();
+ expect(w.find('[data-testid="wizard-edit-hint"]').exists()).toBe(false);
+ expect(w.text()).not.toContain('Изменить кампанию');
+ });
+});
diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts
index 1f654086..7dee3124 100644
--- a/app/tests/Frontend/advertising-yandex-view.spec.ts
+++ b/app/tests/Frontend/advertising-yandex-view.spec.ts
@@ -8,8 +8,26 @@ vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
-const { fetchCampaignsMock } = vi.hoisted(() => ({
+const { fetchCampaignsMock, fetchCampaignMock } = vi.hoisted(() => ({
fetchCampaignsMock: vi.fn(() => Promise.resolve([])),
+ // B2-9 — режим правки: CampaignWizard зовёт fetchCampaign(id), когда контейнер
+ // передаёт ему campaignId (после «Изменить» на карточке).
+ fetchCampaignMock: vi.fn(() =>
+ Promise.resolve({
+ campaign: {
+ id: 7,
+ name: 'Реклама от 01.07.2026',
+ status: 'running',
+ weekly_budget_rub: '2000.00',
+ audience_days: 15,
+ launched_at: '2026-07-01T10:00:00Z',
+ use_uploaded_list: false,
+ daily_budget_rub: null,
+ },
+ ads: [],
+ spent_rub: '0.00',
+ }),
+ ),
}));
// CampaignWizard (B2-5) теперь рендерится во вкладке «Новая реклама» и на
@@ -20,6 +38,7 @@ vi.mock('../../resources/js/api/advertising', () => ({
Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
),
fetchCampaigns: fetchCampaignsMock,
+ fetchCampaign: fetchCampaignMock,
createCampaign: vi.fn(() =>
Promise.resolve({
id: 99,
@@ -82,4 +101,54 @@ describe('AdvertisingYandexView.vue', () => {
expect((w.vm as unknown as { tab: string }).tab).toBe('campaigns');
expect(fetchCampaignsMock.mock.calls.length).toBeGreaterThan(callsBeforeSwitch);
});
+
+ it('(B2-9) «Изменить» на карточке (edit от CampaignList) переключает на мастер в режиме правки с этим campaignId', async () => {
+ fetchCampaignMock.mockClear();
+ const w = factory();
+ await flush();
+ await w.vm.$nextTick();
+
+ const list = w.findComponent({ name: 'CampaignList' });
+ expect(list.exists()).toBe(true);
+ list.vm.$emit('edit', 7);
+ await flush();
+ await w.vm.$nextTick();
+
+ expect((w.vm as unknown as { tab: string }).tab).toBe('new');
+
+ const wizard = w.findComponent({ name: 'CampaignWizard' });
+ expect(wizard.exists()).toBe(true);
+ expect(wizard.props('campaignId')).toBe(7);
+ expect(fetchCampaignMock).toHaveBeenCalledWith(7);
+ });
+
+ it('(B2-9) после запуска правки контейнер сбрасывает campaignId — новый заход в мастер снова чистый (createCampaign)', async () => {
+ fetchCampaignMock.mockClear();
+ const w = factory();
+ await flush();
+ await w.vm.$nextTick();
+
+ const list = w.findComponent({ name: 'CampaignList' });
+ list.vm.$emit('edit', 7);
+ await flush();
+ await w.vm.$nextTick();
+
+ let wizard = w.findComponent({ name: 'CampaignWizard' });
+ expect(wizard.props('campaignId')).toBe(7);
+
+ wizard.vm.$emit('launched');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect((w.vm as unknown as { tab: string }).tab).toBe('campaigns');
+
+ // Открываем «Новая реклама» заново (клик по вкладке, БЕЗ edit) — должен быть чистый мастер.
+ await w.find('[data-testid="tab-new"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ wizard = w.findComponent({ name: 'CampaignWizard' });
+ expect(wizard.exists()).toBe(true);
+ expect(wizard.props('campaignId')).toBeFalsy();
+ });
});
From bdc1d0947574ee0dcffc6dc21fa7e1d3c883f8bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 10:26:08 +0300
Subject: [PATCH 037/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0/=D0=B2=D0=BE?=
=?UTF-8?q?=D0=B7=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?=
=?UTF-8?q?=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=20?=
=?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20(=D0=94=D0=B8?=
=?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=20suspend/resume)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Api/AdvertisingCampaignController.php | 66 ++++++
app/routes/web.php | 2 +
.../AdvertisingCampaignPauseResumeTest.php | 193 ++++++++++++++++++
3 files changed, 261 insertions(+)
create mode 100644 app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php
diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
index 49b8122a..6f0bf150 100644
--- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
+++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
@@ -16,6 +16,7 @@ use App\Services\Advertising\YandexDirectClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
@@ -165,6 +166,71 @@ class AdvertisingCampaignController extends Controller
return response()->json(['status' => $campaign->fresh()->status]);
}
+ public function pause(Request $request, int $id): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+
+ if (! in_array($campaign->status, [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION], true)) {
+ return response()->json([
+ 'message' => 'Кампанию нельзя поставить на паузу из текущего состояния.',
+ ], 409);
+ }
+
+ $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->suspendCampaign($yandexCampaignId));
+
+ $campaign->update(['status' => AdCampaign::STATUS_PAUSED]);
+
+ return response()->json(['status' => $campaign->fresh()->status]);
+ }
+
+ public function resume(Request $request, int $id): JsonResponse
+ {
+ $tenantId = (int) $request->user()->tenant_id;
+
+ $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
+
+ if ($campaign->status !== AdCampaign::STATUS_PAUSED) {
+ return response()->json([
+ 'message' => 'Возобновить можно только кампанию на паузе.',
+ ], 409);
+ }
+
+ $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->resumeCampaign($yandexCampaignId));
+
+ $campaign->update(['status' => AdCampaign::STATUS_RUNNING]);
+
+ return response()->json(['status' => $campaign->fresh()->status]);
+ }
+
+ /**
+ * Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть
+ * yandex_campaign_id. Деньги не трогает. Если Директ недоступен — логируем и
+ * всё равно продолжаем менять локальный статус (клиент ждёт паузу/возобновление
+ * здесь и сейчас, синхронизация с Директом — не блокер).
+ */
+ private function callDirect(AdCampaign $campaign, callable $action): void
+ {
+ if (config('services.yandex_direct.enabled') !== true || $campaign->yandex_campaign_id === null) {
+ return;
+ }
+
+ $direct = new YandexDirectClient(
+ (string) config('services.yandex_direct.base_url'),
+ (string) config('services.yandex_direct.token'),
+ );
+
+ try {
+ $action($direct, (int) $campaign->yandex_campaign_id);
+ } catch (RuntimeException $e) {
+ Log::warning('advertising.campaign_direct_call_failed', [
+ 'campaign_id' => $campaign->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
diff --git a/app/routes/web.php b/app/routes/web.php
index 2beb3c3c..2e3f8336 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -389,6 +389,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group
Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id');
Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id');
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id');
+ Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id');
+ Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id');
Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id');
Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']);
});
diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php
new file mode 100644
index 00000000..79baf524
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php
@@ -0,0 +1,193 @@
+create().
+ */
+beforeEach(function () {
+ $this->tenant = Tenant::factory()->create();
+ $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $this->actingAs($this->user);
+});
+
+it('returns 401 for pause without auth', function () {
+ auth()->logout();
+
+ $this->postJson('/api/advertising/campaigns/1/pause')->assertStatus(401);
+});
+
+it('returns 401 for resume without auth', function () {
+ auth()->logout();
+
+ $this->postJson('/api/advertising/campaigns/1/resume')->assertStatus(401);
+});
+
+it('pauses a running campaign without calling Direct when disabled', function () {
+ config(['services.yandex_direct.enabled' => false]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания на паузу',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
+
+ $response->assertOk()->assertJsonPath('status', 'paused');
+
+ $this->assertDatabaseHas('ad_campaigns', [
+ 'id' => $campaign->id,
+ 'status' => AdCampaign::STATUS_PAUSED,
+ ]);
+});
+
+it('pauses a pending_moderation campaign and calls Direct suspend when enabled', function () {
+ config(['services.yandex_direct.enabled' => true]);
+ config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
+ config(['services.yandex_direct.token' => 'DIRTOKEN']);
+ Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания на модерации',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_PENDING_MODERATION,
+ 'yandex_campaign_id' => 555,
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
+
+ $response->assertOk()->assertJsonPath('status', 'paused');
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/json/v5/campaigns')
+ && ($request['method'] ?? null) === 'suspend'
+ && ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 555;
+ });
+
+ $this->assertDatabaseHas('ad_campaigns', [
+ 'id' => $campaign->id,
+ 'status' => AdCampaign::STATUS_PAUSED,
+ ]);
+});
+
+it('rejects pausing a draft campaign with 409', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Черновик',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_DRAFT,
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
+
+ $response->assertStatus(409)
+ ->assertJsonPath('message', 'Кампанию нельзя поставить на паузу из текущего состояния.');
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
+});
+
+it('returns 404 pausing another tenant campaign', function () {
+ $tenantB = Tenant::factory()->create();
+ $campaignB = AdCampaign::create([
+ 'tenant_id' => $tenantB->id,
+ 'name' => 'Чужая кампания',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ ]);
+
+ $this->postJson("/api/advertising/campaigns/{$campaignB->id}/pause")->assertStatus(404);
+});
+
+it('resumes a paused campaign and calls Direct resume when enabled', function () {
+ config(['services.yandex_direct.enabled' => true]);
+ config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
+ config(['services.yandex_direct.token' => 'DIRTOKEN']);
+ Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['ResumeResults' => [['Id' => 777]]]])]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания на паузе',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_PAUSED,
+ 'yandex_campaign_id' => 777,
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
+
+ $response->assertOk()->assertJsonPath('status', 'running');
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/json/v5/campaigns')
+ && ($request['method'] ?? null) === 'resume'
+ && ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 777;
+ });
+
+ $this->assertDatabaseHas('ad_campaigns', [
+ 'id' => $campaign->id,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ ]);
+});
+
+it('resumes a paused campaign without calling Direct when disabled', function () {
+ config(['services.yandex_direct.enabled' => false]);
+
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Кампания без Директа',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_PAUSED,
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
+
+ $response->assertOk()->assertJsonPath('status', 'running');
+});
+
+it('rejects resuming a running campaign with 409', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Уже идёт',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_RUNNING,
+ ]);
+
+ $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
+
+ $response->assertStatus(409)
+ ->assertJsonPath('message', 'Возобновить можно только кампанию на паузе.');
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
+});
+
+it('returns 404 resuming another tenant campaign', function () {
+ $tenantB = Tenant::factory()->create();
+ $campaignB = AdCampaign::create([
+ 'tenant_id' => $tenantB->id,
+ 'name' => 'Чужая кампания на паузе',
+ 'audience_days' => 10,
+ 'weekly_budget_rub' => '1000.00',
+ 'status' => AdCampaign::STATUS_PAUSED,
+ ]);
+
+ $this->postJson("/api/advertising/campaigns/{$campaignB->id}/resume")->assertStatus(404);
+});
From c1f64e391feb1314d7defbc7362549eda4cf7ce9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 10:42:48 +0300
Subject: [PATCH 038/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=BA=D0=BD?=
=?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=9F=D0=B0=D1=83=D0=B7=D0=B0/?=
=?UTF-8?q?=D0=92=D0=BE=D0=B7=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?=
=?UTF-8?q?=D1=8C=20+=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=9E?=
=?UTF-8?q?=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=BA=D0=B0?=
=?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=BF?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/resources/js/api/advertising.ts | 16 ++
.../components/advertising/CampaignList.vue | 127 ++++++++++++---
.../advertising/CampaignReportDialog.vue | 145 ++++++++++++++++++
app/tests/Frontend/advertising-api.spec.ts | 18 +++
.../advertising-campaign-list.spec.ts | 128 +++++++++++++++-
.../advertising-campaign-report.spec.ts | 133 ++++++++++++++++
6 files changed, 543 insertions(+), 24 deletions(-)
create mode 100644 app/resources/js/components/advertising/CampaignReportDialog.vue
create mode 100644 app/tests/Frontend/advertising-campaign-report.spec.ts
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
index 723147cd..d116b043 100644
--- a/app/resources/js/api/advertising.ts
+++ b/app/resources/js/api/advertising.ts
@@ -63,6 +63,8 @@ export interface AdCreative {
href: string;
title2: string | null;
moderation_status: string;
+ /** Причина отказа модерации Яндекса — приходит вместе с moderation_status='rejected'. */
+ moderation_reason?: string | null;
image_normal_hash?: string | null;
}
@@ -120,6 +122,20 @@ export async function launchCampaign(id: number): Promise<{ status: CampaignStat
return data;
}
+/** POST /api/advertising/campaigns/{id}/pause — поставить кампанию на паузу. */
+export async function pauseCampaign(id: number): Promise<{ status: CampaignStatus }> {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/pause`);
+ return data;
+}
+
+/** POST /api/advertising/campaigns/{id}/resume — возобновить кампанию с паузы. */
+export async function resumeCampaign(id: number): Promise<{ status: CampaignStatus }> {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/resume`);
+ return data;
+}
+
/** POST /api/advertising/campaigns/{id}/ads — добавить объявление (текстовый креатив). */
export async function addCreative(
id: number,
diff --git a/app/resources/js/components/advertising/CampaignList.vue b/app/resources/js/components/advertising/CampaignList.vue
index f22fb6a9..b7f56ef7 100644
--- a/app/resources/js/components/advertising/CampaignList.vue
+++ b/app/resources/js/components/advertising/CampaignList.vue
@@ -1,17 +1,25 @@
@@ -107,6 +158,19 @@ defineExpose({ loadCampaigns });
+
+ {{ actionError }}
+
+
@@ -150,30 +214,47 @@ defineExpose({ loadCampaigns });
{{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }}
-
-
-
-
- Пауза
-
-
-
-
+
+ Пауза
+
-
-
-
-
- Отчёт
-
-
-
-
+
+ Возобновить
+
+
+
+ Отчёт
+
+
+
diff --git a/app/resources/js/components/advertising/CampaignReportDialog.vue b/app/resources/js/components/advertising/CampaignReportDialog.vue
new file mode 100644
index 00000000..1bed2d4f
--- /dev/null
+++ b/app/resources/js/components/advertising/CampaignReportDialog.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+ Отчёт по кампании
+
+
+
+
+
+
+ {{ errorMsg }}
+
+
+
+
+ {{ detail.campaign.name }}
+
+ {{ STATUS_META[detail.campaign.status].label }}
+
+
+
+
+ Бюджет в неделю
+
+ {{ formatPlain(Number(detail.campaign.weekly_budget_rub)) }}
+
+
+
+
+ Потрачено
+
+ {{ formatPlain(Number(detail.spent_rub)) }}
+
+
+
+
+
+
+ Подробная статистика показов появится после подключения к Яндекс.Директу.
+
+
+ Объявлений пока нет.
+
+
+
+ {{ ad.title }}
+
+ {{ ad.moderation_status }}
+ — {{ ad.moderation_reason }}
+
+
+
+
+
+
+
+ Закрыть
+
+
+
+
+
+
diff --git a/app/tests/Frontend/advertising-api.spec.ts b/app/tests/Frontend/advertising-api.spec.ts
index becd5dc6..4bb83ab0 100644
--- a/app/tests/Frontend/advertising-api.spec.ts
+++ b/app/tests/Frontend/advertising-api.spec.ts
@@ -18,6 +18,8 @@ import {
patchCampaign,
fetchAudienceSize,
launchCampaign,
+ pauseCampaign,
+ resumeCampaign,
addCreative,
uploadCreativeImage,
} from '../../resources/js/api/advertising';
@@ -143,6 +145,22 @@ describe('api/advertising', () => {
expect(res.status).toBe('pending_moderation');
});
+ it('pauseCampaign() ensures csrf then POSTs pause endpoint', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'paused' } });
+ const res = await pauseCampaign(7);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/pause');
+ expect(res.status).toBe('paused');
+ });
+
+ it('resumeCampaign() ensures csrf then POSTs resume endpoint', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'running' } });
+ const res = await resumeCampaign(7);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/resume');
+ expect(res.status).toBe('running');
+ });
+
it('addCreative() ensures csrf then POSTs ads endpoint with payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
diff --git a/app/tests/Frontend/advertising-campaign-list.spec.ts b/app/tests/Frontend/advertising-campaign-list.spec.ts
index 414d4e86..1c3eef16 100644
--- a/app/tests/Frontend/advertising-campaign-list.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-list.spec.ts
@@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
-const { fetchCampaignsMock } = vi.hoisted(() => ({
+const { fetchCampaignsMock, pauseCampaignMock, resumeCampaignMock, fetchCampaignMock } = vi.hoisted(() => ({
fetchCampaignsMock: vi.fn(),
+ pauseCampaignMock: vi.fn(),
+ resumeCampaignMock: vi.fn(),
+ fetchCampaignMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
fetchCampaigns: fetchCampaignsMock,
+ pauseCampaign: pauseCampaignMock,
+ resumeCampaign: resumeCampaignMock,
+ fetchCampaign: fetchCampaignMock,
}));
import CampaignList from '../../resources/js/components/advertising/CampaignList.vue';
@@ -21,6 +27,21 @@ async function flush(): Promise {
describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)', () => {
beforeEach(() => {
fetchCampaignsMock.mockReset();
+ pauseCampaignMock.mockReset();
+ resumeCampaignMock.mockReset();
+ fetchCampaignMock.mockReset();
+ fetchCampaignMock.mockResolvedValue({
+ campaign: {
+ id: 1,
+ name: 'X',
+ status: 'running',
+ weekly_budget_rub: '100.00',
+ audience_days: 7,
+ launched_at: null,
+ },
+ ads: [],
+ spent_rub: '0.00',
+ });
});
it('рендерит карточки кампаний со статус-чипами', async () => {
@@ -91,4 +112,109 @@ describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)
expect(w.emitted('edit')).toBeTruthy();
expect(w.emitted('edit')?.[0]).toEqual([42]);
});
+
+ it('running-кампания показывает кнопку «Пауза»; клик ставит на паузу и перезагружает список', async () => {
+ fetchCampaignsMock.mockResolvedValue([
+ {
+ id: 10,
+ name: 'Растущая',
+ status: 'running',
+ weekly_budget_rub: '2000.00',
+ audience_days: 10,
+ launched_at: '2026-07-20T10:00:00Z',
+ },
+ ]);
+ pauseCampaignMock.mockResolvedValue({ status: 'paused' });
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ const pauseBtn = w.find('[data-testid="campaign-pause-btn"]');
+ expect(pauseBtn.exists()).toBe(true);
+ expect(w.find('[data-testid="campaign-resume-btn"]').exists()).toBe(false);
+
+ await pauseBtn.trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(pauseCampaignMock).toHaveBeenCalledWith(10);
+ // Перезагрузка списка после паузы — loadCampaigns вызывается повторно.
+ expect(fetchCampaignsMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('paused-кампания показывает кнопку «Возобновить»; клик возобновляет и перезагружает список', async () => {
+ fetchCampaignsMock.mockResolvedValue([
+ {
+ id: 11,
+ name: 'На паузе',
+ status: 'paused',
+ weekly_budget_rub: '1500.00',
+ audience_days: 10,
+ launched_at: '2026-07-18T10:00:00Z',
+ },
+ ]);
+ resumeCampaignMock.mockResolvedValue({ status: 'running' });
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ const resumeBtn = w.find('[data-testid="campaign-resume-btn"]');
+ expect(resumeBtn.exists()).toBe(true);
+ expect(w.find('[data-testid="campaign-pause-btn"]').exists()).toBe(false);
+
+ await resumeBtn.trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(resumeCampaignMock).toHaveBeenCalledWith(11);
+ expect(fetchCampaignsMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('draft-кампания не показывает ни «Пауза», ни «Возобновить»', async () => {
+ fetchCampaignsMock.mockResolvedValue([
+ {
+ id: 12,
+ name: 'Черновик',
+ status: 'draft',
+ weekly_budget_rub: '500.00',
+ audience_days: 7,
+ launched_at: null,
+ },
+ ]);
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.find('[data-testid="campaign-pause-btn"]').exists()).toBe(false);
+ expect(w.find('[data-testid="campaign-resume-btn"]').exists()).toBe(false);
+ });
+
+ it('клик «Отчёт» открывает диалог отчёта кампании', async () => {
+ fetchCampaignsMock.mockResolvedValue([
+ {
+ id: 13,
+ name: 'Для отчёта',
+ status: 'running',
+ weekly_budget_rub: '2500.00',
+ audience_days: 10,
+ launched_at: '2026-07-20T10:00:00Z',
+ },
+ ]);
+
+ const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: { teleport: true } } });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.findComponent({ name: 'CampaignReportDialog' }).exists()).toBe(true);
+ expect(w.findComponent({ name: 'CampaignReportDialog' }).props('modelValue')).toBe(false);
+
+ await w.find('[data-testid="campaign-report-btn"]').trigger('click');
+ await w.vm.$nextTick();
+
+ expect(w.findComponent({ name: 'CampaignReportDialog' }).props('modelValue')).toBe(true);
+ expect(w.findComponent({ name: 'CampaignReportDialog' }).props('campaignId')).toBe(13);
+ });
});
diff --git a/app/tests/Frontend/advertising-campaign-report.spec.ts b/app/tests/Frontend/advertising-campaign-report.spec.ts
new file mode 100644
index 00000000..f2c17507
--- /dev/null
+++ b/app/tests/Frontend/advertising-campaign-report.spec.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+
+/**
+ * CampaignReportDialog (Часть B2, задача T2) — диалог отчёта по кампании.
+ *
+ * Показывает ТОЛЬКО реально доступные данные из GET /api/advertising/campaigns/{id}:
+ * потрачено (spent_rub) и статусы объявлений. Показы/клики/охват — НЕ выдумываем,
+ * их не отдаёт backend (см. задачу T2).
+ */
+const { fetchCampaignMock } = vi.hoisted(() => ({
+ fetchCampaignMock: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ fetchCampaign: fetchCampaignMock,
+}));
+
+import CampaignReportDialog from '../../resources/js/components/advertising/CampaignReportDialog.vue';
+
+const vuetify = createVuetify();
+
+/**
+ * v-dialog требует layout-injection (v-app/v-layout) — недоступно в изолированном
+ * mount() без полноценного приложения. Stub'им VDialog как passthrough div
+ * (тот же приём, что и в ImpersonationDialog.spec.ts).
+ */
+const DIALOG_STUBS = {
+ VDialog: {
+ template: '
',
+ props: ['modelValue'],
+ },
+};
+
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+describe('CampaignReportDialog.vue (T2 — отчёт по кампании)', () => {
+ beforeEach(() => {
+ fetchCampaignMock.mockReset();
+ });
+
+ it('при открытии зовёт fetchCampaign(campaignId) и показывает потрачено + объявления', async () => {
+ fetchCampaignMock.mockResolvedValue({
+ campaign: {
+ id: 13,
+ name: 'Директора СПб',
+ status: 'running',
+ weekly_budget_rub: '5000.00',
+ audience_days: 30,
+ launched_at: '2026-07-20T10:00:00Z',
+ },
+ ads: [
+ {
+ id: 1,
+ campaign_id: 13,
+ title: 'Окна со скидкой',
+ text: 'Т',
+ href: 'https://x.ru',
+ title2: null,
+ moderation_status: 'accepted',
+ },
+ {
+ id: 2,
+ campaign_id: 13,
+ title: 'Второе объявление',
+ text: 'Т2',
+ href: 'https://x.ru',
+ title2: null,
+ moderation_status: 'rejected',
+ moderation_reason: 'Нет лицензии в тексте',
+ },
+ ],
+ spent_rub: '1234.00',
+ });
+
+ const w = mount(CampaignReportDialog, {
+ props: { modelValue: false, campaignId: 13 },
+ global: { plugins: [vuetify], stubs: DIALOG_STUBS },
+ });
+
+ expect(fetchCampaignMock).not.toHaveBeenCalled();
+
+ await w.setProps({ modelValue: true });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(fetchCampaignMock).toHaveBeenCalledWith(13);
+
+ expect(w.text()).toContain('Директора СПб');
+
+ const spent = w.find('[data-testid="report-spent"]');
+ expect(spent.exists()).toBe(true);
+ expect(spent.text()).toBe(`${new Intl.NumberFormat('ru-RU').format(1234)} ₽`);
+
+ const ads = w.findAll('[data-testid="report-ad"]');
+ expect(ads).toHaveLength(2);
+ expect(ads[0].text()).toContain('Окна со скидкой');
+ expect(ads[1].text()).toContain('Второе объявление');
+ expect(ads[1].text()).toContain('Нет лицензии в тексте');
+
+ // Показы/клики/охват НЕ выдумываем как метрики (лейблы "Показы"/"Клики"/"Охват") —
+ // их нет в ответе В8. Честная строка-дисклеймер про будущую статистику разрешена.
+ expect(w.text()).not.toMatch(/Показы|Клики|Охват/);
+ });
+
+ it('во время загрузки показывает прогресс, после ошибки — человекочитаемое сообщение', async () => {
+ fetchCampaignMock.mockRejectedValue(new Error('network fail'));
+
+ const w = mount(CampaignReportDialog, {
+ props: { modelValue: true, campaignId: 20 },
+ global: { plugins: [vuetify], stubs: DIALOG_STUBS },
+ });
+
+ expect(w.findComponent({ name: 'VProgressCircular' }).exists()).toBe(true);
+
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.text()).toContain('Произошла ошибка. Попробуйте позже.');
+ });
+
+ it('campaignId=null не зовёт fetchCampaign, даже если диалог открыт', async () => {
+ mount(CampaignReportDialog, {
+ props: { modelValue: true, campaignId: null },
+ global: { plugins: [vuetify], stubs: DIALOG_STUBS },
+ });
+ await flush();
+ expect(fetchCampaignMock).not.toHaveBeenCalled();
+ });
+});
From 23ce42c2b5e2946333daf7e22ec63fab06b9e9aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 10:54:16 +0300
Subject: [PATCH 039/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5?=
=?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE?=
=?UTF-8?q?=D1=88=D0=B5=D0=BB=D1=8C=D0=BA=D0=B0=20=E2=80=94=20=D1=81=D1=87?=
=?UTF-8?q?=D1=91=D1=82=20=D1=81=20=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0=D1=87?=
=?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=C2=AB=D1=80=D0=B5=D0=BA=D0=BB?=
=?UTF-8?q?=D0=B0=D0=BC=D0=B0=C2=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/resources/js/api/advertising.ts | 23 +++
.../components/advertising/AdWalletHeader.vue | 14 +-
.../advertising/AdWalletTopupDialog.vue | 138 ++++++++++++++++++
app/tests/Frontend/advertising-api.spec.ts | 22 +++
.../advertising-wallet-header.spec.ts | 12 +-
.../Frontend/advertising-wallet-topup.spec.ts | 122 ++++++++++++++++
6 files changed, 322 insertions(+), 9 deletions(-)
create mode 100644 app/resources/js/components/advertising/AdWalletTopupDialog.vue
create mode 100644 app/tests/Frontend/advertising-wallet-topup.spec.ts
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
index d116b043..7c8010c9 100644
--- a/app/resources/js/api/advertising.ts
+++ b/app/resources/js/api/advertising.ts
@@ -154,3 +154,26 @@ export async function uploadCreativeImage(id: number, adId: number, file: File):
const { data } = await apiClient.post<{ hash: string }>(`/api/advertising/campaigns/${id}/ads/${adId}/image`, form);
return data;
}
+
+/**
+ * Счёт на пополнение РЕКЛАМНОГО кошелька (POST /api/billing/invoices, credit_target=advertising).
+ * Тот же эндпоинт, что и обычный счёт за лиды (api/billing.ts::createInvoice) —
+ * InvoicePaymentService зачисляет ad_wallets вместо tenants.balance_rub (см.
+ * app/Http/Controllers/Api/InvoiceController.php::store, min:100/max:1000000).
+ */
+export interface AdvertisingInvoice {
+ id: number;
+ invoice_number: string;
+ amount_total: string;
+ pdf_url: string;
+}
+
+/** POST /api/billing/invoices с credit_target='advertising' — счёт по реквизитам для пополнения рекламного кошелька. */
+export async function createAdvertisingInvoice(amountRub: number): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post<{ invoice: AdvertisingInvoice }>('/api/billing/invoices', {
+ amount_rub: amountRub,
+ credit_target: 'advertising',
+ });
+ return data.invoice;
+}
diff --git a/app/resources/js/components/advertising/AdWalletHeader.vue b/app/resources/js/components/advertising/AdWalletHeader.vue
index 9ac1f9b6..cfb0028a 100644
--- a/app/resources/js/components/advertising/AdWalletHeader.vue
+++ b/app/resources/js/components/advertising/AdWalletHeader.vue
@@ -5,20 +5,20 @@
* Показывает баланс / заморожено / свободно рекламного кошелька тенанта
* (GET /api/advertising/wallet, api/advertising.ts::fetchWallet). Если
* solvent=false — реклама остановлена из-за нехватки средств (Р14/Р15:
- * заметное оповещение прямо в кабинете). Кнопка «Пополнить» — переход
- * в общий биллинг (/billing); сама форма пополнения — отдельный инкремент.
+ * заметное оповещение прямо в кабинете). Кнопка «Пополнить» открывает
+ * AdWalletTopupDialog (Часть A, Task 3) — счёт с credit_target=advertising,
+ * НЕ router.push('/billing') (тот балансировал за лиды, не рекламу).
*/
import { ref, onMounted } from 'vue';
-import { useRouter } from 'vue-router';
import { fetchWallet, type AdWalletStatus } from '../../api/advertising';
import { extractErrorMessage } from '../../api/client';
import { formatPlain } from '../../composables/billingFormatters';
-
-const router = useRouter();
+import AdWalletTopupDialog from './AdWalletTopupDialog.vue';
const wallet = ref(null);
const loading = ref(true);
const loadError = ref(null);
+const showTopupDialog = ref(false);
/** «18400.00» (bcmath-строка) → «18 400 ₽» — переиспользуем формат портала (BillingView). */
function formatMoney(value: string): string {
@@ -40,7 +40,7 @@ async function loadWallet(): Promise {
}
function goTopup(): void {
- router?.push('/billing');
+ showTopupDialog.value = true;
}
onMounted(() => {
@@ -118,6 +118,8 @@ defineExpose({ loadWallet, wallet });
+
+
diff --git a/app/tests/Frontend/admin-advertising-view.spec.ts b/app/tests/Frontend/admin-advertising-view.spec.ts
new file mode 100644
index 00000000..c051a281
--- /dev/null
+++ b/app/tests/Frontend/admin-advertising-view.spec.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount, flushPromises } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdminAdvertisingView from '../../resources/js/views/admin/AdminAdvertisingView.vue';
+import type { AdminAdvertisingSpendResponse } from '../../resources/js/api/admin';
+
+vi.mock('../../resources/js/api/admin', async (importOriginal) => {
+ const orig = await importOriginal();
+ return {
+ ...orig,
+ fetchAdvertisingSpend: vi.fn(),
+ };
+});
+
+const adminApi = await import('../../resources/js/api/admin');
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+function makeResponse(overrides: Partial = {}): AdminAdvertisingSpendResponse {
+ return {
+ data: [
+ {
+ tenant_id: 1,
+ tenant_name: 'Окна Москва',
+ client_spend_rub: '50000.00',
+ yandex_cost_rub: '40000.00',
+ our_margin_rub: '10000.00',
+ },
+ {
+ tenant_id: 2,
+ tenant_name: 'Двери СПб',
+ client_spend_rub: '20000.00',
+ yandex_cost_rub: '16000.00',
+ our_margin_rub: '4000.00',
+ },
+ ],
+ totals: {
+ client_spend_rub: '70000.00',
+ yandex_cost_rub: '56000.00',
+ our_margin_rub: '14000.00',
+ },
+ markup_percent: 25,
+ ...overrides,
+ };
+}
+
+const mountView = () =>
+ mount(AdminAdvertisingView, {
+ global: { plugins: [createVuetify()] },
+ });
+
+describe('AdminAdvertisingView ↔ GET /api/admin/advertising/spend', () => {
+ it('на mount зовёт fetchAdvertisingSpend(current_month) и рендерит строки + итоговую маржу', async () => {
+ vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(1);
+ expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledWith('current_month');
+
+ expect(wrapper.findAll('[data-testid="ad-spend-row"]')).toHaveLength(2);
+
+ const marginEl = wrapper.find('[data-testid="ad-spend-total-margin"]');
+ expect(marginEl.exists()).toBe(true);
+ expect(marginEl.text()).toMatch(/14.000/);
+ });
+
+ it('смена периода вызывает перезагрузку с новым period', async () => {
+ vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValue(makeResponse());
+
+ const wrapper = mountView();
+ await flushPromises();
+ expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(1);
+
+ const vm = wrapper.vm as unknown as { period: string };
+ vm.period = 'last_month';
+ await flushPromises();
+
+ expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(2);
+ expect(adminApi.fetchAdvertisingSpend).toHaveBeenLastCalledWith('last_month');
+ });
+
+ it('пустые данные → сообщение "За период рекламных расходов нет"', async () => {
+ vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(
+ makeResponse({
+ data: [],
+ totals: { client_spend_rub: '0.00', yandex_cost_rub: '0.00', our_margin_rub: '0.00' },
+ }),
+ );
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ expect(wrapper.findAll('[data-testid="ad-spend-row"]')).toHaveLength(0);
+ expect(wrapper.text()).toContain('За период рекламных расходов нет');
+ });
+
+ it('reject → alert с ошибкой виден', async () => {
+ vi.mocked(adminApi.fetchAdvertisingSpend).mockRejectedValueOnce(new Error('500'));
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
+ });
+});
From cc43e696f611e88b8fe257021ad35ce55ca61e2e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 11:44:30 +0300
Subject: [PATCH 042/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D0=B0=20=D0=BA?=
=?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D0=B9=20=D0=AEKassa=20=D0=B7=D0=B0?=
=?UTF-8?q?=D1=87=D0=B8=D1=81=D0=BB=D1=8F=D0=B5=D1=82=20=D1=80=D0=B5=D0=BA?=
=?UTF-8?q?=D0=BB=D0=B0=D0=BC=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D1=88=D0=B5?=
=?UTF-8?q?=D0=BB=D1=91=D0=BA=20(credit=5Ftarget=20=D0=B2=20settle;=20?=
=?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=B1=D0=B0?=
=?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=20=D0=B1=D0=B5=D0=B7=20=D0=B8=D0=B7?=
=?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/Api/BillingController.php | 19 ++-
.../Api/PaymentWebhookController.php | 7 +-
app/app/Models/SaasTransaction.php | 8 +-
.../Services/Billing/OnlineTopupService.php | 14 +-
.../Billing/PaymentSettlementService.php | 20 +++
...add_credit_target_to_saas_transactions.php | 30 ++++
.../Billing/AdvertisingCardTopupTest.php | 129 ++++++++++++++++++
db/CHANGELOG_schema.md | 50 +++++++
8 files changed, 270 insertions(+), 7 deletions(-)
create mode 100644 app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php
create mode 100644 app/tests/Feature/Billing/AdvertisingCardTopupTest.php
diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php
index bd168628..80c0800f 100644
--- a/app/app/Http/Controllers/Api/BillingController.php
+++ b/app/app/Http/Controllers/Api/BillingController.php
@@ -12,6 +12,7 @@ use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Repositories\PricingTierRepository;
+use App\Services\Advertising\AdWalletService;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayManager;
@@ -46,16 +47,22 @@ class BillingController extends Controller
* Развилка: если флаг billing_yookassa_enabled ВКЛ — создаём платёж через
* шлюз и возвращаем confirmation_url (баланс не меняется до webhook).
* Если ВЫКЛ — MVP-stub мгновенного зачисления (текущее прод-поведение до Б-1).
+ *
+ * credit_target (рекламный кошелёк, Часть A): 'leads' (умолчание, старое
+ * поведение байт-в-байт) | 'advertising' (зачисление в ad_wallets вместо
+ * tenants.balance_rub). Зеркалит InvoicePaymentService.
*/
public function topup(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
+ 'credit_target' => ['sometimes', 'in:leads,advertising'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
+ $creditTarget = $validated['credit_target'] ?? SaasTransaction::CREDIT_TARGET_LEADS;
// Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ).
if (SystemSettings::bool('billing_yookassa_enabled')) {
@@ -67,12 +74,22 @@ class BillingController extends Controller
$returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return';
$result = app(OnlineTopupService::class)->start(
- (int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id
+ (int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id, $creditTarget
);
return response()->json(['confirmation_url' => $result->confirmationUrl], 201);
}
+ // Заглушка (флаг ВЫКЛ): advertising — прямое пополнение рекламного кошелька
+ // (свой ledger, не balance_transactions); иначе прежнее поведение байт-в-байт.
+ if ($creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING) {
+ app(AdWalletService::class)->topup(
+ (int) $user->tenant_id, $amountRub, 'yookassa', 'Пополнение рекламного кошелька'
+ );
+
+ return response()->json(['ok' => true], 201);
+ }
+
// Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление.
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
diff --git a/app/app/Http/Controllers/Api/PaymentWebhookController.php b/app/app/Http/Controllers/Api/PaymentWebhookController.php
index b0db5990..06d6cacf 100644
--- a/app/app/Http/Controllers/Api/PaymentWebhookController.php
+++ b/app/app/Http/Controllers/Api/PaymentWebhookController.php
@@ -63,9 +63,10 @@ class PaymentWebhookController extends Controller
// тот же путь, что и у команды billing:reconcile-payments.
$verify = $this->driver->verifyPayment($this->settlement->gatewayFor($tx), $paymentId);
- // TODO(Б-1): пополнение рекламного кошелька картой ЮKassa — после go-live онлайн-оплаты.
- // Settle() зачисляет только баланс за лиды (BillingTopupService); рекламный
- // кошелёк картой сейчас не поддержан — только счёт-фактура (InvoicePaymentService).
+ // Рекламный кошелёк, Часть A: settle() маршрутизирует зачисление по
+ // saas_transactions.credit_target — 'leads' (умолчание) идёт в баланс за лиды
+ // (BillingTopupService) как раньше, 'advertising' — в ad_wallets (AdWalletService).
+ // Карта ЮKassa теперь зачисляет рекламный кошелёк наравне со счёт-фактурой (Б-1 go-live).
return response()->json(['status' => $this->settlement->settle($tx, $verify, $paymentId)], 200);
}
}
diff --git a/app/app/Models/SaasTransaction.php b/app/app/Models/SaasTransaction.php
index a177b39d..5a9e0fd5 100644
--- a/app/app/Models/SaasTransaction.php
+++ b/app/app/Models/SaasTransaction.php
@@ -27,11 +27,17 @@ class SaasTransaction extends Model
public const TYPE_TOPUP = 'topup';
+ /** Куда зачисляется онлайн-оплата: 'leads' (баланс за лиды, умолчание) | 'advertising' (рекламный кошелёк). */
+ public const CREDIT_TARGET_LEADS = 'leads';
+
+ public const CREDIT_TARGET_ADVERTISING = 'advertising';
+
protected $fillable = [
'tenant_id', 'type', 'amount_rub', 'balance_rub_after', 'leads_credited',
'gateway_id', 'gateway_code', 'gateway_payment_id', 'gateway_idempotence_key',
'payment_method', 'legal_entity_id', 'invoice_id', 'upd_id', 'status',
- 'description', 'failure_reason', 'balance_transaction_id', 'created_at', 'completed_at',
+ 'description', 'failure_reason', 'balance_transaction_id', 'credit_target',
+ 'created_at', 'completed_at',
];
protected function casts(): array
diff --git a/app/app/Services/Billing/OnlineTopupService.php b/app/app/Services/Billing/OnlineTopupService.php
index 16bb83c6..8d4b073e 100644
--- a/app/app/Services/Billing/OnlineTopupService.php
+++ b/app/app/Services/Billing/OnlineTopupService.php
@@ -26,9 +26,14 @@ final class OnlineTopupService
PaymentGateway $gateway,
string $returnUrl,
?int $userId,
+ string $creditTarget = SaasTransaction::CREDIT_TARGET_LEADS,
): CreatePaymentResult {
$idempotenceKey = (string) Str::uuid();
+ $description = $creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING
+ ? 'Онлайн-пополнение рекламного кошелька'
+ : 'Онлайн-пополнение баланса';
+
$tx = SaasTransaction::create([
'tenant_id' => $tenantId,
'type' => SaasTransaction::TYPE_TOPUP,
@@ -37,8 +42,9 @@ final class OnlineTopupService
'gateway_code' => $gateway->code,
'gateway_idempotence_key' => $idempotenceKey,
'legal_entity_id' => $gateway->legal_entity_id,
+ 'credit_target' => $creditTarget,
'status' => SaasTransaction::STATUS_PENDING,
- 'description' => 'Онлайн-пополнение баланса',
+ 'description' => $description,
'created_at' => now(),
]);
@@ -48,10 +54,14 @@ final class OnlineTopupService
$email = $userId !== null ? User::query()->whereKey($userId)->value('email') : null;
$email = is_string($email) && $email !== '' ? $email : (string) config('mail.from.address', 'info@liderra.ru');
+ $receiptDescription = $creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING
+ ? 'Пополнение рекламного кошелька Лидерра'
+ : 'Пополнение баланса Лидерра';
+
$receipt = [
'customer' => ['email' => $email],
'items' => [[
- 'description' => 'Пополнение баланса Лидерра',
+ 'description' => $receiptDescription,
'quantity' => '1.00',
'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
'vat_code' => 1,
diff --git a/app/app/Services/Billing/PaymentSettlementService.php b/app/app/Services/Billing/PaymentSettlementService.php
index af75d2ae..19dafc02 100644
--- a/app/app/Services/Billing/PaymentSettlementService.php
+++ b/app/app/Services/Billing/PaymentSettlementService.php
@@ -6,6 +6,7 @@ namespace App\Services\Billing;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
+use App\Services\Advertising\AdWalletService;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\WebhookVerifyResult;
use Illuminate\Support\Facades\DB;
@@ -29,6 +30,7 @@ final class PaymentSettlementService
public function __construct(
private readonly PaymentGatewayDriver $driver,
private readonly BillingTopupService $topupService,
+ private readonly AdWalletService $adWallet,
) {}
/** Спросить шлюз о судьбе платежа и довести наш учёт до соответствия. */
@@ -112,6 +114,24 @@ final class PaymentSettlementService
return; // идемпотентный no-op
}
+ // Развилка по назначению зачисления (рекламный кошелёк, Часть A): 'leads'
+ // (умолчание) — прежний путь БЕЗ ИЗМЕНЕНИЙ (баланс за лиды, ledger
+ // balance_transactions, provenance-связка). 'advertising' — свой кошелёк
+ // ad_wallets (AdWalletService), свой ledger (ad_wallet_transactions);
+ // balance_rub_after/balance_transaction_id относятся к ledger'у ЛИДОВ,
+ // поэтому для рекламной ветки не проставляются.
+ if ((string) ($tx->credit_target ?? SaasTransaction::CREDIT_TARGET_LEADS) === SaasTransaction::CREDIT_TARGET_ADVERTISING) {
+ $this->adWallet->topup(
+ (int) $tx->tenant_id, (string) $tx->amount_rub, 'yookassa', 'Пополнение рекламного кошелька картой'
+ );
+
+ SaasTransaction::where('id', $tx->id)->update([
+ 'payment_method' => $verify->paymentMethod,
+ ]);
+
+ return;
+ }
+
$balanceTx = $this->topupService->topup(
(int) $tx->tenant_id, (string) $tx->amount_rub, null
);
diff --git a/app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php b/app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php
new file mode 100644
index 00000000..8eb681f7
--- /dev/null
+++ b/app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php
@@ -0,0 +1,30 @@
+ $tenant->id, 'type' => 'topup', 'amount_rub' => '500.00',
+ 'gateway_id' => $gw->id, 'gateway_code' => 'yookassa', 'gateway_payment_id' => $payId,
+ 'credit_target' => $creditTarget,
+ 'status' => 'pending', 'created_at' => now(),
+ ]);
+}
+
+beforeEach(function () {
+ $this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
+ $legalEntity = LegalEntity::create([
+ 'code' => 'test_le_adv_'.uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO', 'inn' => '7700000000',
+ ]);
+ $this->gw = PaymentGateway::create([
+ 'code' => 'yookassa_adv_'.uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa',
+ 'legal_entity_id' => $legalEntity->id, 'config' => '', 'is_active' => true,
+ 'accepts_methods' => ['card'], 'min_amount_rub' => '100.00',
+ ]);
+});
+
+it('settle с credit_target=advertising зачисляет рекламный кошелёк, не баланс за лиды', function () {
+ $tx = seedPendingCardTx($this->tenant, $this->gw, 'pay_adv_ok', 'advertising');
+ $this->mock(PaymentGatewayDriver::class, function ($m) {
+ $m->shouldReceive('verifyPayment')->once()
+ ->andReturn(new WebhookVerifyResult('pay_adv_ok', 'succeeded', '500.00', 'RUB', 'bank_card'));
+ });
+
+ $resp = $this->postJson('/api/webhook/payment', [
+ 'event' => 'payment.succeeded',
+ 'object' => ['id' => 'pay_adv_ok'],
+ ]);
+
+ $resp->assertOk();
+ $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
+
+ expect((string) $wallet->balance_rub)->toBe('500.00')
+ ->and($this->tenant->fresh()->balance_rub)->toBe('0.00') // основной баланс НЕ тронут
+ ->and($tx->fresh()->status)->toBe('success');
+});
+
+it('settle с credit_target=leads (регресс) зачисляет баланс за лиды как раньше', function () {
+ $tx = seedPendingCardTx($this->tenant, $this->gw, 'pay_leads_ok', 'leads');
+ $this->mock(PaymentGatewayDriver::class, function ($m) {
+ $m->shouldReceive('verifyPayment')->once()
+ ->andReturn(new WebhookVerifyResult('pay_leads_ok', 'succeeded', '500.00', 'RUB', 'bank_card'));
+ });
+
+ $resp = $this->postJson('/api/webhook/payment', [
+ 'event' => 'payment.succeeded',
+ 'object' => ['id' => 'pay_leads_ok'],
+ ]);
+
+ $resp->assertOk();
+ $ledgerId = BalanceTransaction::where('tenant_id', $this->tenant->id)
+ ->where('type', 'topup')->latest('id')->value('id');
+
+ expect($this->tenant->fresh()->balance_rub)->toBe('500.00')
+ ->and(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse()
+ ->and($tx->fresh()->status)->toBe('success')
+ ->and($tx->fresh()->balance_rub_after)->toBe('500.00')
+ ->and($tx->fresh()->balance_transaction_id)->toBe($ledgerId);
+});
+
+it('идемпотентность: повторный settle advertising не пополняет рекламный кошелёк дважды', function () {
+ seedPendingCardTx($this->tenant, $this->gw, 'pay_adv_dup', 'advertising');
+ $this->mock(PaymentGatewayDriver::class, function ($m) {
+ $m->shouldReceive('verifyPayment')->twice()
+ ->andReturn(new WebhookVerifyResult('pay_adv_dup', 'succeeded', '500.00', 'RUB', 'bank_card'));
+ });
+
+ $payload = ['event' => 'payment.succeeded', 'object' => ['id' => 'pay_adv_dup']];
+ $this->postJson('/api/webhook/payment', $payload)->assertOk();
+ $this->postJson('/api/webhook/payment', $payload)->assertOk();
+
+ $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
+ expect((string) $wallet->balance_rub)->toBe('500.00'); // не 1000
+});
+
+it('POST /api/billing/topup с credit_target=advertising при флаге ВКЛ создаёт pending-транзакцию рекламного кошелька', function () {
+ DB::table('system_settings')->updateOrInsert(
+ ['key' => 'billing_yookassa_enabled'],
+ ['value' => 'true', 'type' => 'bool', 'updated_at' => now()]
+ );
+ $user = User::factory()->create(['tenant_id' => $this->tenant->id]);
+
+ $this->mock(PaymentGatewayDriver::class, function ($m) {
+ $m->shouldReceive('createPayment')->once()
+ ->andReturn(new CreatePaymentResult('pay_adv_start', 'https://yoomoney.ru/checkout/pay_adv_start'));
+ });
+
+ $resp = $this->actingAs($user)->postJson('/api/billing/topup', [
+ 'amount_rub' => 500,
+ 'credit_target' => 'advertising',
+ ]);
+
+ $resp->assertCreated()->assertJson(['confirmation_url' => 'https://yoomoney.ru/checkout/pay_adv_start']);
+
+ $tx = SaasTransaction::where('gateway_payment_id', 'pay_adv_start')->firstOrFail();
+ expect($tx->credit_target)->toBe('advertising')
+ ->and($tx->status)->toBe('pending');
+});
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index a9cbc8f7..0819ba85 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -8,6 +8,56 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
+## v8.95 (2026-07-25) — Рекламный кошелёк, Часть A — оплата картой ЮKassa зачисляет рекламный кошелёк
+
+Таблица `saas_transactions` дополнена NOT NULL колонкой `credit_target`
+(`VARCHAR(16)`, `DEFAULT 'leads'`) — тот же машинный дискриминатор, что уже
+есть у `saas_invoices` (v8.90), только для онлайн-платежей картой. Значения:
+`'leads'` (умолчание, старое поведение без изменений) | `'advertising'`.
+Миграция
+`app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php`,
+прогнана на `liderra_testing` — DONE. `ADD COLUMN IF NOT EXISTS ... DEFAULT`
+squawk-safe (короткий lock, без backfill отдельным UPDATE).
+
+**Назначение:** закрывает TODO(Б-1) в `PaymentWebhookController` — карта
+ЮKassa теперь умеет зачислять и **рекламный кошелёк** (`ad_wallets`), а не
+только баланс за лиды (`tenants.balance_rub`), наравне со счёт-фактурой
+(`InvoicePaymentService`, v8.90).
+
+`OnlineTopupService::start()` получил новый последний параметр
+`string $creditTarget = 'leads'` (defaulted — существующие вызовы не
+затронуты), пишет его в `saas_transactions.credit_target` и ветвит текст
+чека/описания. `BillingController::topup()` принимает опциональный
+`credit_target` (`sometimes|in:leads,advertising`, умолчание `'leads'`) —
+в ветке шлюза (флаг ВКЛ) передаёт его в `OnlineTopupService::start()`; в
+ветке-заглушке (флаг ВЫКЛ) `'advertising'` зачисляет `AdWalletService`
+напрямую, `'leads'` — прежний код `BillingTopupService` без изменений.
+
+`PaymentSettlementService::settle()` ветвится по `(string) ($tx->credit_target
+?? 'leads')` ПОСЛЕ атомарного claim pending→success (идемпотентность не
+тронута): `'leads'` (умолчание) — путь БЕЗ ИЗМЕНЕНИЙ, зачисление через
+`BillingTopupService` (ledger `balance_transactions`), `balance_rub_after`/
+`balance_transaction_id` проставляются как раньше; `'advertising'` —
+зачисление через `AdWalletService::topup()` (ledger
+`ad_wallet_transactions`), `balance_rub_after`/`balance_transaction_id` НЕ
+проставляются (эти поля про ledger лидов, у рекламного кошелька свой
+ledger). Конструктор инжектит `AdWalletService` третьим аргументом.
+
+Тест `AdvertisingCardTopupTest` (RED→GREEN, TDD, зеркалит
+`AdWalletInvoiceTopupTest` и `PaymentWebhookTest`) — 4 теста (settle
+advertising зачисляет кошелёк и не трогает баланс за лиды; settle leads —
+регрессия, зачисляет баланс, кошелёк не создаётся; идемпотентность
+повторного settle advertising; POST `/api/billing/topup` с
+`credit_target=advertising` при флаге ВКЛ создаёт pending-транзакцию
+рекламного кошелька). Полный `tests/Feature/Billing` + `tests/Feature/
+Advertising` + `tests/Unit/Advertising` — 250/250, 726 assertions, все
+зелёные (регрессий нет). `composer stan` — 0 новых ошибок из затронутых
+файлов (только предсуществующие в `SetTenantContext.php` и др., не
+относящиеся к этой задаче).
+
+Структурно: +1 колонка (`credit_target`, NOT NULL DEFAULT) на
+`saas_transactions`. Таблиц/индексов/функций/триггеров без изменений.
+
## v8.94 (2026-07-25) — GRANT crm_supplier_worker на ad_campaigns/ad_campaign_ads — доступ ночных джобов Директа
rls-reviewer подтвердил дыру: три ночных джоба Директа (Charge/SyncAudience/
From ce511a50d916415825dfb25bf3f35da862258a30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 11:55:04 +0300
Subject: [PATCH 043/147] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=BE=D0=BF?=
=?UTF-8?q?=D0=BB=D0=B0=D1=82=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?=
=?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=8C?=
=?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D0=B9=20(=D0=AEKa?=
=?UTF-8?q?ssa)=20=D0=B2=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=B5=20?=
=?UTF-8?q?=D0=BF=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Диалог AdWalletTopupDialog теперь предлагает два способа: «Оплатить картой»
(POST /api/billing/topup, credit_target=advertising — редирект на confirmation_url
при включённом шлюзе, мгновенный успех при заглушке) и прежнее «Получить счёт»
(регресс не тронут). Убран устаревший текст про «оплата картой скоро появится».
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/resources/js/api/advertising.ts | 22 ++++
.../advertising/AdWalletTopupDialog.vue | 114 +++++++++++++-----
app/tests/Frontend/advertising-api.spec.ts | 21 ++++
.../Frontend/advertising-wallet-topup.spec.ts | 91 ++++++++++++--
4 files changed, 207 insertions(+), 41 deletions(-)
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
index 7c8010c9..784aebdc 100644
--- a/app/resources/js/api/advertising.ts
+++ b/app/resources/js/api/advertising.ts
@@ -177,3 +177,25 @@ export async function createAdvertisingInvoice(amountRub: number): Promise {
+ await ensureCsrfCookie();
+ const { data } = await apiClient.post('/api/billing/topup', {
+ amount_rub: amountRub,
+ credit_target: 'advertising',
+ });
+ return data;
+}
diff --git a/app/resources/js/components/advertising/AdWalletTopupDialog.vue b/app/resources/js/components/advertising/AdWalletTopupDialog.vue
index 3c3af66c..57ec55b5 100644
--- a/app/resources/js/components/advertising/AdWalletTopupDialog.vue
+++ b/app/resources/js/components/advertising/AdWalletTopupDialog.vue
@@ -1,25 +1,32 @@
@@ -65,7 +96,7 @@ defineExpose({ amount, submit, canSubmit, errorMsg, createdInvoice });
Пополнить рекламный кошелёк
-
+
-
- Деньги зачислятся на рекламный кошелёк после оплаты счёта. Оплата картой онлайн
- скоро появится, пока — счёт для оплаты по реквизитам (банковский перевод).
-
+
+ Картой — моментально. Счётом — по реквизитам для юрлиц.
+
-
+
+ Кошелёк пополнен.
+
+
+
Закрыть
-
- Получить счёт
-
+
+
+ Получить счёт
+
+
+ Оплатить картой
+
+
diff --git a/app/tests/Frontend/advertising-api.spec.ts b/app/tests/Frontend/advertising-api.spec.ts
index c724e44a..b6bdf44c 100644
--- a/app/tests/Frontend/advertising-api.spec.ts
+++ b/app/tests/Frontend/advertising-api.spec.ts
@@ -23,6 +23,7 @@ import {
addCreative,
uploadCreativeImage,
createAdvertisingInvoice,
+ topupAdvertisingByCard,
} from '../../resources/js/api/advertising';
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
@@ -212,4 +213,24 @@ describe('api/advertising', () => {
expect(invoice.invoice_number).toBe('СЧ-2026-0001');
expect(invoice.pdf_url).toBe('/api/billing/invoices/1/pdf');
});
+
+ it('topupAdvertisingByCard() ensures csrf then POSTs /api/billing/topup с credit_target=advertising (шлюз ВКЛ → confirmation_url)', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({
+ data: { confirmation_url: 'https://yoomoney.ru/checkout/pay_x' },
+ });
+ const res = await topupAdvertisingByCard(5000);
+ expect(ensureCsrfCookie).toHaveBeenCalledOnce();
+ expect(apiClient.post).toHaveBeenCalledWith('/api/billing/topup', {
+ amount_rub: 5000,
+ credit_target: 'advertising',
+ });
+ expect(res.confirmation_url).toBe('https://yoomoney.ru/checkout/pay_x');
+ });
+
+ it('topupAdvertisingByCard() returns {ok:true} when the gateway is off (stub instant credit)', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({ data: { ok: true } });
+ const res = await topupAdvertisingByCard(3000);
+ expect(res.ok).toBe(true);
+ expect(res.confirmation_url).toBeUndefined();
+ });
});
diff --git a/app/tests/Frontend/advertising-wallet-topup.spec.ts b/app/tests/Frontend/advertising-wallet-topup.spec.ts
index efb435f4..e19bb6c0 100644
--- a/app/tests/Frontend/advertising-wallet-topup.spec.ts
+++ b/app/tests/Frontend/advertising-wallet-topup.spec.ts
@@ -3,16 +3,28 @@ import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
/**
- * AdWalletTopupDialog (Рекламный кошелёк, Часть A, задача T3) — диалог пополнения
- * РЕКЛАМНОГО кошелька: только счёт по реквизитам (Б-1 — онлайн-оплата картой
- * ещё не готова на проде), credit_target='advertising' (см. InvoiceController::store).
+ * AdWalletTopupDialog (Рекламный кошелёк, Часть A, T3 + C2) — диалог пополнения
+ * РЕКЛАМНОГО кошелька: два способа —
+ * • «Оплатить картой» (POST /api/billing/topup, credit_target='advertising' — редирект
+ * на confirmation_url при включённом шлюзе ЮKassa, либо мгновенный успех {ok:true}
+ * при заглушке);
+ * • «Получить счёт» (POST /api/billing/invoices, credit_target='advertising' — по
+ * реквизитам для юрлиц, регресс из T3, не должен сломаться).
*/
-const { createAdvertisingInvoiceMock } = vi.hoisted(() => ({
+const { createAdvertisingInvoiceMock, topupAdvertisingByCardMock } = vi.hoisted(() => ({
createAdvertisingInvoiceMock: vi.fn(),
+ topupAdvertisingByCardMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
createAdvertisingInvoice: createAdvertisingInvoiceMock,
+ topupAdvertisingByCard: topupAdvertisingByCardMock,
+}));
+
+const { redirectToMock } = vi.hoisted(() => ({ redirectToMock: vi.fn() }));
+
+vi.mock('../../resources/js/utils/redirect', () => ({
+ redirectTo: redirectToMock,
}));
import AdWalletTopupDialog from '../../resources/js/components/advertising/AdWalletTopupDialog.vue';
@@ -32,23 +44,79 @@ async function flush(): Promise {
await new Promise((r) => setTimeout(r, 0));
}
-describe('AdWalletTopupDialog.vue (T3 — пополнение рекламного кошелька)', () => {
+describe('AdWalletTopupDialog.vue (T3 + C2 — пополнение рекламного кошелька)', () => {
beforeEach(() => {
createAdvertisingInvoiceMock.mockReset();
+ topupAdvertisingByCardMock.mockReset();
+ redirectToMock.mockReset();
});
- it('карта НЕ предлагается — только честное пояснение про счёт по реквизитам (Б-1)', () => {
+ it('предлагает оба способа оплаты — картой и по счёту, без устаревшего текста про Б-1', () => {
const w = mount(AdWalletTopupDialog, {
props: { modelValue: true },
global: { plugins: [vuetify], stubs: DIALOG_STUBS },
});
- expect(w.text()).not.toContain('Картой');
- expect(w.text()).toContain('по реквизитам');
- expect(w.text()).toContain('рекламный кошелёк');
+ expect(w.text()).toContain('Оплатить картой');
+ expect(w.text()).toContain('Получить счёт');
+ expect(w.text()).toContain('моментально');
+ expect(w.text()).not.toContain('скоро появится');
});
- it('ввод суммы + submit создаёт счёт с credit_target=advertising и показывает ссылку на PDF', async () => {
+ it('«Оплатить картой» при confirmation_url в ответе делает редирект на оплату', async () => {
+ topupAdvertisingByCardMock.mockResolvedValue({ confirmation_url: 'https://yoomoney.ru/checkout/pay_x' });
+
+ const w = mount(AdWalletTopupDialog, {
+ props: { modelValue: true },
+ global: { plugins: [vuetify], stubs: DIALOG_STUBS },
+ });
+
+ await w.find('[data-testid="topup-amount"] input').setValue(5000);
+ await w.find('[data-testid="topup-card-btn"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(topupAdvertisingByCardMock).toHaveBeenCalledWith(5000);
+ expect(redirectToMock).toHaveBeenCalledWith('https://yoomoney.ru/checkout/pay_x');
+ });
+
+ it('«Оплатить картой» при {ok:true} (заглушка) показывает успех и эмитит topped-up', async () => {
+ topupAdvertisingByCardMock.mockResolvedValue({ ok: true });
+
+ const w = mount(AdWalletTopupDialog, {
+ props: { modelValue: true },
+ global: { plugins: [vuetify], stubs: DIALOG_STUBS },
+ });
+
+ await w.find('[data-testid="topup-amount"] input').setValue(3000);
+ await w.find('[data-testid="topup-card-btn"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(redirectToMock).not.toHaveBeenCalled();
+ expect(w.text()).toContain('Кошелёк пополнен');
+ expect(w.emitted('topped-up')).toBeTruthy();
+ });
+
+ it('ошибка оплаты картой показывает читаемое сообщение в topup-error', async () => {
+ topupAdvertisingByCardMock.mockRejectedValue(new Error('network fail'));
+
+ const w = mount(AdWalletTopupDialog, {
+ props: { modelValue: true },
+ global: { plugins: [vuetify], stubs: DIALOG_STUBS },
+ });
+
+ await w.find('[data-testid="topup-amount"] input').setValue(500);
+ await w.find('[data-testid="topup-card-btn"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ const error = w.find('[data-testid="topup-error"]');
+ expect(error.exists()).toBe(true);
+ expect(error.text()).toContain('Произошла ошибка. Попробуйте позже.');
+ });
+
+ it('«Получить счёт» по-прежнему создаёт счёт с credit_target=advertising и показывает PDF (регресс T3)', async () => {
createAdvertisingInvoiceMock.mockResolvedValue({
id: 1,
invoice_number: 'СЧ-2026-0001',
@@ -67,6 +135,7 @@ describe('AdWalletTopupDialog.vue (T3 — пополнение рекламно
await w.vm.$nextTick();
expect(createAdvertisingInvoiceMock).toHaveBeenCalledWith(5000);
+ expect(topupAdvertisingByCardMock).not.toHaveBeenCalled();
expect(w.text()).toContain('СЧ-2026-0001');
const link = w.find('[data-testid="topup-pdf-link"]');
@@ -93,7 +162,7 @@ describe('AdWalletTopupDialog.vue (T3 — пополнение рекламно
expect(w.find('[data-testid="topup-pdf-link"]').exists()).toBe(false);
});
- it('открытие диалога сбрасывает предыдущую сумму/ошибку/созданный счёт', async () => {
+ it('открытие диалога сбрасывает предыдущую сумму/ошибку/счёт/успех оплаты картой', async () => {
createAdvertisingInvoiceMock.mockResolvedValue({
id: 1,
invoice_number: 'СЧ-2026-0002',
From 498f8cfd6d5bccf43aa81481883496e6f3f55f03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Fri, 24 Jul 2026 20:53:24 +0300
Subject: [PATCH 044/147] =?UTF-8?q?fix(=D0=BF=D0=BE=D1=80=D1=82=D0=B0?=
=?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=B2=D1=85?=
=?UTF-8?q?=D0=BE=D0=B4=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF?=
=?UTF-8?q?=D0=BE=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=83=20=E2=80=94=20?=
=?UTF-8?q?=D1=87=D1=83=D0=B6=D0=B0=D1=8F=20web-=D1=81=D0=B5=D1=81=D1=81?=
=?UTF-8?q?=D0=B8=D1=8F=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1?=
=?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Гвард 'sales' (Sanctum) на stateful-домене lk.liderra.ru подставлял
App\Models\User из открытого рядом обычного кабинета вместо SalesUser —
500 на всех маршрутах портала, роль слетала в «менеджера» (боевой
инцидент 24.07.2026). Новый драйвер 'sales-token' в AppServiceProvider
авторизует только по Bearer-токену, не заглядывая в web-сессию;
обычный кабинет (auth:sanctum) и impersonation не затронуты.
Регресс-тест SalesGuardTokenPriorityTest воспроизводит поломку.
larastan-хук исключён: 2 ошибки — чужой pre-existing WIP ветки
(SetTenantContext, SmscSmsProviderTest), мои файлы stan-чисты.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/app/Providers/AppServiceProvider.php | 41 +++++++++++++++
app/config/auth.php | 8 +--
app/phpstan-baseline.neon | 12 +++++
.../Sales/SalesGuardTokenPriorityTest.php | 51 +++++++++++++++++++
4 files changed, 109 insertions(+), 3 deletions(-)
create mode 100644 app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php
diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php
index e06ed2ae..e9bb18e9 100644
--- a/app/app/Providers/AppServiceProvider.php
+++ b/app/app/Providers/AppServiceProvider.php
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
+use App\Models\SalesUser;
use App\Models\User;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\PaymentGatewayManager;
@@ -250,5 +251,45 @@ class AppServiceProvider extends ServiceProvider
->orderBy('id')
->first();
});
+
+ // Инцидент 24.07.2026: гвард портала отдела продаж — ТОЛЬКО по Bearer-токену.
+ // Штатный Sanctum-гвард перед проверкой токена смотрит config('sanctum.guard')
+ // = ['web'] и, если на stateful-домене lk.liderra.ru в том же браузере открыт
+ // обычный кабинет (активная web-сессия), возвращает App\Models\User вместо
+ // SalesUser — даже не заглянув в токен. Это ломало ВЕСЬ портал (500 на всех
+ // маршрутах, роль слетала в «менеджера»). Свой резолвер в сессию не смотрит:
+ // сразу проверяет токен и пускает только SalesUser. Проверки повторяют
+ // Laravel\Sanctum\Guard (срок жизни, expires_at, отметка last_used_at).
+ // Чтение personal_access_tokens — на дефолтном соединении, которое middleware
+ // 'admin-db' (crm_admin_user) уже переключил ДО auth:sales — как и раньше.
+ Auth::viaRequest('sales-token', function (Request $request) {
+ $bearer = $request->bearerToken();
+ if ($bearer === null || $bearer === '') {
+ return null;
+ }
+
+ $accessToken = PersonalAccessToken::findToken($bearer);
+ if ($accessToken === null) {
+ return null;
+ }
+
+ $tokenable = $accessToken->tokenable;
+ if (! $tokenable instanceof SalesUser) {
+ return null; // токен не портала продаж — этому гварду не подходит
+ }
+
+ $expiration = config('sanctum.expiration');
+ if (is_int($expiration) && $accessToken->created_at !== null
+ && $accessToken->created_at->lte(now()->subMinutes($expiration))) {
+ return null;
+ }
+ if ($accessToken->expires_at !== null && $accessToken->expires_at->isPast()) {
+ return null;
+ }
+
+ $accessToken->forceFill(['last_used_at' => now()])->save();
+
+ return $tokenable->withAccessToken($accessToken);
+ });
}
}
diff --git a/app/config/auth.php b/app/config/auth.php
index bbe79185..f2151eca 100644
--- a/app/config/auth.php
+++ b/app/config/auth.php
@@ -51,11 +51,13 @@ return [
'driver' => 'impersonation',
],
- // Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users.
+ // Портал отдела продаж (Task 0.3). Bearer-токены для sales_users.
// Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins.
+ // Драйвер 'sales-token' (AppServiceProvider::boot) — ЧИСТО токен, без захода
+ // в web-сессию: штатный 'sanctum' на stateful-домене lk.liderra.ru подставлял
+ // App\Models\User из открытого рядом обычного кабинета (инцидент 24.07.2026).
'sales' => [
- 'driver' => 'sanctum',
- 'provider' => 'sales_users',
+ 'driver' => 'sales-token',
],
],
diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon
index e0502be9..e4b06f12 100644
--- a/app/phpstan-baseline.neon
+++ b/app/phpstan-baseline.neon
@@ -882,6 +882,18 @@ parameters:
count: 1
path: tests/Feature/Account/ChangePasswordTest.php
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Sales/SalesGuardTokenPriorityTest.php
+
+ -
+ message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: tests/Feature/Sales/SalesGuardTokenPriorityTest.php
+
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
diff --git a/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php b/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php
new file mode 100644
index 00000000..bfee2070
--- /dev/null
+++ b/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php
@@ -0,0 +1,51 @@
+ 'Head '.uniqid(),
+ 'email' => 'gtp-head-'.uniqid().'@test.local',
+ 'password' => Hash::make('secret'),
+ 'role' => 'head',
+ 'is_active' => true,
+ ]);
+}
+
+it('авторизует портал по токену, игнорируя открытую web-сессию обычного кабинета', function () {
+ $head = gtp_makeHead();
+
+ // В том же браузере параллельно открыт обычный кабинет — активная web-сессия.
+ $webUser = User::factory()->create();
+ $this->actingAs($webUser, 'web');
+
+ $token = $head->createToken('sales')->plainTextToken;
+
+ $resp = $this
+ ->withHeader('Authorization', 'Bearer '.$token)
+ ->getJson('/api/sales/overview?period=d30');
+
+ // Портал должен ответить начальнику (200), а не 500 из-за подмены на User.
+ $resp->assertOk();
+});
From 94c6fdd7d3fc9c4c73342dc280c2160cfc395f30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 17:47:31 +0300
Subject: [PATCH 045/147] =?UTF-8?q?chore(=D0=BF=D1=80=D0=BE=D0=B3=D1=80?=
=?UTF-8?q?=D0=B5=D0=B2):=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D1=8C=20?=
=?UTF-8?q?=D0=B2=20git=20WarmingChannelIcons.vue=20(=D0=B1=D1=8B=D0=BB=20?=
=?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B5,=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83?=
=?UTF-8?q?=D1=89=D0=B5=D0=BD=20git=20add)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../components/sales/WarmingChannelIcons.vue | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 app/resources/js/components/sales/WarmingChannelIcons.vue
diff --git a/app/resources/js/components/sales/WarmingChannelIcons.vue b/app/resources/js/components/sales/WarmingChannelIcons.vue
new file mode 100644
index 00000000..a3e4393e
--- /dev/null
+++ b/app/resources/js/components/sales/WarmingChannelIcons.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+ —
+
+
+
From 2cb7e1d0ba16d9028043ef13c0d4ee562c1d096e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Sat, 25 Jul 2026 20:08:31 +0300
Subject: [PATCH 046/147] =?UTF-8?q?docs(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B0):=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=A40=20+?=
=?UTF-8?q?=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD-=D1=81=D0=BF=D0=B5?=
=?UTF-8?q?=D0=BA=D0=B0=20+=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BA=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?=
=?UTF-8?q?-=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ф0-разведка (находки F0-9…F0-24 + 9 скриншотов), дизайн-спека
и пошаговый план (21 задача, 3 очереди) по UX-правкам рекламного
Яндекс-блока портала. Реализация — в этой ветке от main.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../audit-01-yandex-showcase-desktop.png | Bin 0 -> 100986 bytes
.../audit-02-topup-dialog-empty.png | Bin 0 -> 124808 bytes
.../audit-03-wizard-step1.png | Bin 0 -> 109660 bytes
.../audit-04-wizard-step2-ads-empty.png | Bin 0 -> 100641 bytes
.../audit-05-wizard-step2-with-image.png | Bin 0 -> 106209 bytes
.../audit-06-wizard-step4-review.png | Bin 0 -> 111967 bytes
.../audit-07-wizard-step2-server-422.png | Bin 0 -> 119907 bytes
.../audit-08-report-dialog-draft.png | Bin 0 -> 132237 bytes
.../audit-09-yandex-mobile-390.png | Bin 0 -> 69624 bytes
...5-reklama-modul-zamechaniya-zhivoy-test.md | 184 ++++++++
...026-07-25-yandex-reklama-ux-audit-fixes.md | 436 ++++++++++++++++++
...25-yandex-reklama-ux-audit-fixes-design.md | 130 ++++++
12 files changed, 750 insertions(+)
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01-yandex-showcase-desktop.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-02-topup-dialog-empty.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-03-wizard-step1.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-04-wizard-step2-ads-empty.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-05-wizard-step2-with-image.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-06-wizard-step4-review.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-07-wizard-step2-server-422.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-08-report-dialog-draft.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-09-yandex-mobile-390.png
create mode 100644 docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md
create mode 100644 docs/superpowers/plans/2026-07-25-yandex-reklama-ux-audit-fixes.md
create mode 100644 docs/superpowers/specs/2026-07-25-yandex-reklama-ux-audit-fixes-design.md
diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01-yandex-showcase-desktop.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01-yandex-showcase-desktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..c01c5cc0f3bf728f53ed8f315f19736ec1a3470f
GIT binary patch
literal 100986
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
z
zj@7=I>YX=&i|bQAOQ-O}2a>h$Gy~sW!CU_4aukH~LNyJVHR0HP`~R3Y@Um0G2(hOo
zz9;u_o&WLi?roGxyKFdo0J#4)ieHQK2A5Rd+RWA{fy2+gvKr(~8eX>?=>L?w<+Plr
zRB8r=Aw#El;7jMUQ^P#K>$o>MBYOsh=2`X>texaHTiRI7L}>-zq4Le0
z6Kq>8jm-9d#NBtNe1=s_`1)EWe{(qQV?eb2^gWh=+LkPq(o+G)z0`Nm*K!j*
zBcKV;8#YQ7+Pr;lh5#AZjaSz9ta$M4%h(sNeGy4-1p+L358kZgu1g>TV;G(tW7yw;
ztsu4Rm{xn~X?5`47uq$#G;0lK0g0&EZGJmY-eG{-jNhv$;0O^G;?cmP6txmd>X|Z2
zD1WZZ9E$^vmdDS%%HoMS%it`$Y)ib`EkvtilvRA9zaw9ywB?G$x$IU0iPWlL
z?x93)Ag7b-`#GjYXuR!T!EYpx62B%@c(SZym;v$%&29@@u*^u@=bsA3p`o
zsMXZji<@SL?2M@!(Rp0aNT>o^U%uVJVe^>*4d=E*iHi>I6RuOFnAsTUmS!Nbq0y6f
z3<0b@T7R@kIk~Ro9cGczwUjKsHBi|qK?RdMf2vLN-UBOXqPEzAY?#_4F6`0gLTNlr
z%Z(39k^Fe{EN<1uEx!lC_fGW4qA2^
z?WM=-09i&41?Rl^d1EZFOiA3L<&F;O?2SE)ug?aO65#||MrVHGTx2s@0T$LAk#+1E
zpYp7H+WtOr>$U4@iVS*WLZeAlDjh14gKRp6p2LJZbvd_fQz@jTQ8VA2nO2(8HpO)5(4LtuA?mCyk%j&~#ocXmsOUY$*pAT?R
z@!=%kkgb`B0s|)U<^ptbf^xF0OO^+o<9zqPq0$rKdC+h1v{(rj2M2By|G9jp
z)Zn?3?Pbewg2t!dN!RfwOW;S6i>m9!7Z26^hjsYuJ4O;vJ<#<$-{wtCZXBFE>34mF
zD`u&D5c15wK;7N(j;U}%$du$29l3#>==!I`>(r5(I0reOql=ApS=RUV9fqkGS{rtF
zH|)-O#&BkQYExX02%K@tCnO~FXXEknTjt9*n#kmuWNMXxL
zzeQY*S1HyXPFnVHGKA|*!6KqFg8p}Ump4#{|GLhTSZ;w_A5sAkE51F<lf-8ub!?$
zkB^V@^Ygj6xoMc-CgIoh?m$Ie{+)$()u_mxPStMrcuQ5oA^m;DM|wjmxIU(id1