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(); + } +} 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 @@