From 3fdfd92c9e0dcf151f7dbf9784c705e22b7e2b3c 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, 23 May 2026 19:25:00 +0300 Subject: [PATCH] =?UTF-8?q?docs(billing-v2):=20=D1=81=D0=BF=D0=B5=D0=BA=20?= =?UTF-8?q?B=20=E2=80=94=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20(=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 задач: baseline → таблица-замок supplier_lead_deliveries → раздача по клиентам (LeadRouter DISTINCT ON) → удаление DuplicateDetector из обоих джобов → замок insertOrIgnore → тесты (model-agnostic) → регрессия. Вариант B. Заякорено на always-rub LedgerService (Спек A в origin/main). Co-Authored-By: Claude Opus 4.7 --- ...05-23-billing-v2-spec-b-duplicates-plan.md | 757 ++++++++++++++++++ 1 file changed, 757 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-billing-v2-spec-b-duplicates-plan.md diff --git a/docs/superpowers/plans/2026-05-23-billing-v2-spec-b-duplicates-plan.md b/docs/superpowers/plans/2026-05-23-billing-v2-spec-b-duplicates-plan.md new file mode 100644 index 00000000..7160e437 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-billing-v2-spec-b-duplicates-plan.md @@ -0,0 +1,757 @@ +# Биллинг v2 Спек B — политика дублей: план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Убрать наш телефонный антифрод-фильтр дублей (доверяем дедупу поставщика), но гарантировать на уровне БД, что одна поставка одному клиенту тарифицируется ровно один раз; лимит шеринга — 3 разных клиента. + +**Architecture:** Удаляем `DuplicateDetector` из обоих job-путей. В шеринг-пути (`RouteSupplierLeadJob`) раздача переводится с лимита-по-проектам на лимит-по-клиентам (один проект на клиента — DISTINCT ON по `tenant_id`, выбор проекта с макс. остатком дневного лимита; cap=3 клиента). Новая таблица-замок `supplier_lead_deliveries` (PK `supplier_lead_id`+`tenant_id`) + `insertOrIgnore` внутри транзакции создания сделки гарантирует «одна поставка → один оплаченный лид на клиента» даже при гонках/перезапусках/CSV-восстановлении. + +**Tech Stack:** Laravel 13, PostgreSQL 16 (партиционированная `deals`, RLS по `app.current_tenant_id`, 5 ролей), Pest 4 (`--parallel`), bcmath/`LedgerService`. Worktree `.claude/worktrees/billing-v2-spec-b/`, ветка `feat/billing-v2-spec-b` (база origin/main `ff2ee59e`, Спек A уже влит). + +**Спека:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md` + +--- + +## ⚠️ Важный контекст базы (прочитать до старта) + +1. **Спек A влит в origin/main.** `App\Services\Billing\LedgerService::chargeForDelivery` — always-rub: списывает `balance_rub` (bcmath), пишет `LeadCharge(charge_source='rub')` + `BalanceTransaction` + `supplier_lead_costs`; `balance_leads` НЕ трогает. Возвращает `ChargeResult`. +2. **Тест-долг Спека A.** Часть существующих тестов (`app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` ассертит `balance_leads → 99`; `RouteSupplierLeadJobBillingTest.php` имеет кейс `charge_source='prepaid'`) противоречит always-rub `LedgerService` и, вероятно, **уже красная на этой базе**. Это НЕ наша регрессия. Task 1 устанавливает фактический baseline. Новые тесты Спека B заякорены на **model-agnostic** ассерты (число `Deal` / `LeadCharge` на клиента + строки таблицы-замка) и сетап через хелпер `prepareSharingFlow` с достаточным `balance_rub`, чтобы не зависеть от prepaid/rub. +3. **Два job-пути.** `ProcessWebhookJob` (прямой вебхук, `WebhookReceiveController`) — идемпотентность по `vid` через `webhook_dedup_keys (tenant_id, source_crm_id)`; замок там НЕ нужен. `RouteSupplierLeadJob` (шеринг, `SupplierWebhookController` + `CsvReconcileJob`) — замок нужен здесь. +4. **Гранты — blanket.** `db/02_grants.sql` выдаёт `GRANT ... ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES`. Новая tenant-таблица грантов отдельно не требует. На dev — `postgres` superuser. +5. **`duplicate_detected` в origin/main отсутствует** (ни в `db/schema.sql`, ни во фронте, ни в backend) — чистить нечего, только verify-grep. Колонка `deals.duplicate_of_id` (schema.sql:1626) + индекс (schema.sql:1688) — есть. + +--- + +## File Structure + +| Файл | Действие | Ответственность | +|---|---|---| +| `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql` | Create | DDL таблицы-замка (RLS + PK + FK) | +| `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php` | Create | парная Laravel-миграция (idempotency guard) | +| `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql` | Create | DROP лишнего индекса | +| `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` | Create | парная Laravel-миграция | +| `db/schema.sql` | Modify | +CREATE TABLE supplier_lead_deliveries; −CREATE INDEX deals(duplicate_of_id); header v8.32→v8.33 | +| `db/CHANGELOG_schema.md` | Modify | +запись v8.33 | +| `app/app/Models/SupplierLeadDelivery.php` | Create | Eloquent-модель замка | +| `app/app/Services/DuplicateDetector.php` | Delete | сервис телефонного фильтра | +| `app/app/Jobs/ProcessWebhookJob.php` | Modify | убрать findMaster + markAsDuplicate, всегда charge | +| `app/app/Jobs/RouteSupplierLeadJob.php` | Modify | убрать DuplicateDetector из сигнатур; +замок insertOrIgnore; раздача по клиентам | +| `app/app/Services/LeadRouter.php` | Modify | DISTINCT ON (tenant_id) — один проект на клиента (макс. остаток лимита) | +| `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` | Create | тесты замка + раздачи по клиентам | +| `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` | Modify | убрать DuplicateDetector из `runRouteJob`; удалить/переписать дубль-тесты | +| `app/tests/Feature/ProcessWebhookJobTest.php` | Modify | убрать дубль-тесты; +тест «два vid, один телефон → оба charge» | +| прочие тесты с `DuplicateDetector`/`runRouteJob` | Modify | привести сигнатуры к 6-арговому handle() | + +--- + +## Task 1: Baseline — зафиксировать фактическое состояние + +**Files:** нет правок (только прогон). + +- [ ] **Step 1: Подготовить тестовую БД worktree** + +Run: +```bash +cd .claude/worktrees/billing-v2-spec-b/app +php artisan migrate:fresh --env=testing +php artisan partitions:create-months --env=testing +``` +Expected: миграции проходят; партиции `deals_*`, `balance_transactions_*`, `supplier_lead_costs_*` за текущий/смежные месяцы созданы. (Квирк Спека A: при нехватке партиций тесты падают с partition-ошибкой — пересоздать.) + +- [ ] **Step 2: Прогнать затронутые сюиты, записать baseline** + +Run: +```bash +php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/ProcessWebhookJobTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Pd/DealCreatePdLogTest.php +``` +Expected: записать в заметку, какие тесты GREEN, какие RED. Ожидаемо красные (тест-долг Спека A, НЕ наша задача): `RouteSupplierLeadJobTest` (balance_leads ассерты), prepaid-кейс в `RouteSupplierLeadJobBillingTest`. Всё остальное должно быть GREEN. + +- [ ] **Step 3: Подтвердить модель списания** + +Run: +```bash +grep -n "charge_source\|balance_rub\|balance_leads" app/Services/Billing/LedgerService.php +``` +Expected: `charge_source` = `'rub'` хардкод, списывается `balance_rub`. Зафиксировать: новые тесты используют `balance_rub` и `LeadCharge::count()`. + +- [ ] **Step 4: Коммит заметки baseline (опционально)** + +Если ведёте журнал — зафиксируйте baseline-вывод в описании задачи. Кода-коммита нет. + +--- + +## Task 2: Таблица-замок `supplier_lead_deliveries` + +**Files:** +- Create: `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql` +- Create: `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php` +- Modify: `db/schema.sql` (вставить CREATE TABLE; header v8.32→v8.33) +- Modify: `db/CHANGELOG_schema.md` +- Create: `app/app/Models/SupplierLeadDelivery.php` +- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (schema-часть) + +- [ ] **Step 1: Написать падающий schema-тест** + +Создать `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`: +```php +pluck('column_name')->all(); + + expect($cols)->toContain('supplier_lead_id') + ->toContain('tenant_id') + ->toContain('deal_id') + ->toContain('created_at'); + + $pk = collect(DB::select( + "SELECT a.attname FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary" + ))->pluck('attname')->sort()->values()->all(); + expect($pk)->toBe(['supplier_lead_id', 'tenant_id']); + + $rls = DB::selectOne( + "SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'" + ); + expect($rls->relrowsecurity)->toBeTrue(); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` +Expected: FAIL (таблицы нет). + +- [ ] **Step 3: Написать DDL-файл миграции** + +Создать `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`: +```sql +-- ============================================================================= +-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз» +-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону — +-- разные поставки с одним телефоном остаются отдельными платными лидами. +-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей +-- при гонках / перезапусках задачи / CSV-восстановлении. +-- ============================================================================= +CREATE TABLE supplier_lead_deliveries ( + supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (supplier_lead_id, tenant_id) +); + +ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON supplier_lead_deliveries + USING (tenant_id = current_setting('app.current_tenant_id')::bigint); +``` + +- [ ] **Step 4: Написать парную Laravel-миграцию** + +Создать `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`: +```php +seed(PricingTierSeeder::class); + + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru', + ]); + $tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']); + + // Два подходящих проекта одного клиента, разный остаток лимита. + $pLow = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 9, 'delivery_days_mask' => 127, + ]); + $pHigh = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, + ]); + linkProjectToSupplier($pLow, $sp); + linkProjectToSupplier($pHigh, $sp); + + $vid = 600001; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, + 'phone' => '79991234567', + 'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], + ]); + + runRouteJob($lead->id); + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); + expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + // Выбран проект с наибольшим остатком лимита. + expect($pHigh->fresh()->delivered_today)->toBe(1); + expect($pLow->fresh()->delivered_today)->toBe(9); +}); +``` +NB: `runRouteJob` уже определён в `RouteSupplierLeadJobTest.php`, но это другой файл. Определить локальный хелпер в этом файле (после Task 4 он будет 6-арговым — см. ниже), либо вызвать job напрямую. Чтобы не зависеть от Task 4, в этом тесте вызвать job через `app()`-резолв 6 аргументов ПОСЛЕ Task 4. Поэтому: написать тело теста, но запускать его в Step 3 уже после правки LeadRouter, а полную зелёность по job — в Task 6. + +- [ ] **Step 2: Переписать `LeadRouter::matchEligibleProjects` на DISTINCT ON (tenant_id)** + +Заменить тело `matchEligibleProjects` в `app/app/Services/LeadRouter.php` — добавить `DISTINCT ON (projects.tenant_id)` с выбором проекта максимального остатка лимита: +```php + /** @var Collection $candidates */ + $candidates = Project::on('pgsql_supplier') + ->select('projects.*') + ->selectRaw('DISTINCT ON (projects.tenant_id) projects.id AS __distinct_marker') + ->whereExists(function ($q) use ($supplierProject): void { + $q->selectRaw('1') + ->from('project_supplier_links') + ->whereColumn('project_supplier_links.project_id', 'projects.id') + ->where('project_supplier_links.supplier_project_id', $supplierProject->id); + }) + ->where('is_active', true) + ->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit]) + ->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)') + ->whereExists(function ($q): void { + $q->selectRaw('1') + ->from('tenants') + ->whereColumn('tenants.id', 'projects.tenant_id') + ->where(function ($qq): void { + $qq->where('tenants.balance_leads', '>', 0) + ->orWhere('tenants.balance_rub', '>', 0); + }); + }) + ->orderBy('projects.tenant_id') + ->orderByRaw('COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC') + ->orderBy('projects.created_at') + ->orderBy('projects.id') + ->get(); + + return $candidates->values(); +``` +NB: смешение `DISTINCT ON` + Eloquent `select('projects.*')` хрупко. **Предпочтительный вариант** — сырой select без маркера: +```php + $candidates = Project::on('pgsql_supplier') + ->fromRaw('projects') + ->whereExists(/* project_supplier_links ... */) + ->where('is_active', true) + ->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit]) + ->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)') + ->whereExists(/* tenants balance ... */) + ->orderByRaw('projects.tenant_id, COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC, projects.created_at, projects.id') + ->selectRaw('DISTINCT ON (projects.tenant_id) projects.*') + ->get(); +``` +Реализатор выбирает рабочий из двух (проверить SQL прогоном). Семантика обязательна: **ровно один Project на tenant_id, с максимальным остатком `COALESCE(effective_daily_limit_today, daily_limit_target) - delivered_today`; тай-брейк `created_at, id`**. + +- [ ] **Step 3: Прогон существующих router-зависимых тестов** + +Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php --filter="caps deal creation at 3"` +Expected: тест cap=3 (5 клиентов по 1 проекту) остаётся GREEN (DISTINCT ON не меняет результат при одном проекте на клиента). Если упал из-за DuplicateDetector-аргумента — это чинится в Task 4; здесь убедиться, что SQL DISTINCT ON валиден (нет SQL-ошибки). + +- [ ] **Step 4: Коммит** + +```bash +git add app/app/Services/LeadRouter.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php +git commit -m "feat(billing-v2): LeadRouter — one project per tenant (max remaining limit)" +``` + +--- + +## Task 4: Удалить `DuplicateDetector` из `RouteSupplierLeadJob` + +**Files:** +- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` +- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (сигнатура `runRouteJob`, удалить дубль-тесты) + +- [ ] **Step 1: Убрать DuplicateDetector из `handle()` и `createDealCopyForProject()`** + +В `app/app/Jobs/RouteSupplierLeadJob.php`: +- Удалить `use App\Services\DuplicateDetector;`. +- Из сигнатуры `handle(...)` убрать параметр `DuplicateDetector $duplicateDetector,`. +- Из вызова `$this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)` убрать `$duplicateDetector`. +- Из сигнатуры `createDealCopyForProject(...)` убрать параметр `DuplicateDetector $duplicateDetector,`. +- Удалить блок поиска master + ветку дубля (строки ~274–306: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && $master->id !== $deal->id) { ... return false; }`). Сделка всегда идёт на `chargeForDelivery`. +- Обновить doc-комментарии (убрать упоминания DuplicateDetector/Биз-19/duplicate_of_id). + +- [ ] **Step 2: Обновить тест-хелпер и удалить дубль-тесты** + +В `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`: +- Убрать `use App\Services\DuplicateDetector;`. +- В `runRouteJob()` и в инлайн-вызове теста «caps deal creation at 3» убрать аргумент `app(DuplicateDetector::class),` (handle() теперь 6-арговый). +- Удалить тест `it('marks duplicate via DuplicateDetector — no charge ...')` (строки ~158–204) — концепция удалена. +- Переписать тест `it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean')` → новое имя/поведение: pre-existing deal с тем же телефоном (другой `vid`) НЕ подавляет списание; ожидать `deals_created_count = 3`, все три баланса/счётчики списаны. (См. также Task 7 — там добавляются model-agnostic тесты; здесь достаточно убрать `duplicate_of_id`-ассерты и привести ожидание к «3 charged».) + +- [ ] **Step 3: Прогон** + +Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php` +Expected: тесты, не завязанные на `balance_leads`-долг, GREEN; компиляция (6-арговый handle) проходит. Красные строго из-за `balance_leads`-ассертов (тест-долг Спека A) — допустимо; если задача включает их починку, мигрировать на `balance_rub` (см. Task 7 Step 4). + +- [ ] **Step 4: Коммит** + +```bash +git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php +git commit -m "refactor(billing-v2): drop DuplicateDetector from RouteSupplierLeadJob (Spec B)" +``` + +--- + +## Task 5: Удалить `DuplicateDetector` из `ProcessWebhookJob` + сам сервис + +**Files:** +- Modify: `app/app/Jobs/ProcessWebhookJob.php` +- Delete: `app/app/Services/DuplicateDetector.php` +- Modify: `app/tests/Feature/ProcessWebhookJobTest.php` + +- [ ] **Step 1: Написать падающий тест «два vid, один телефон → оба charge»** + +В `app/tests/Feature/ProcessWebhookJobTest.php` добавить (сверить сетап с существующими тестами файла — tenant с балансом, dispatch `ProcessWebhookJob`): +```php +it('charges both leads with same phone but different vid (no phone dedup)', function (): void { + // Сетап tenant + project как в соседних тестах файла. + // Прогнать ProcessWebhookJob дважды: тот же phone, разные vid. + // Ожидать: 2 Deal, баланс списан дважды, ни у одной нет duplicate_of_id. + // (точный сетап — по образцу существующих тестов ProcessWebhookJobTest) +})->todo(); +``` +Затем заменить `->todo()` на полноценный тест по образцу существующего «новая сделка списывает баланс» из этого же файла (взять его сетап tenant/project/payload, продублировать вызов с двумя разными `vid`, одинаковым `phone`; ассертить 2 сделки + двойное списание). + +- [ ] **Step 2: Запустить — убедиться, что падает (или показывает старое поведение)** + +Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php --filter="same phone but different vid"` +Expected: при наличии DuplicateDetector второй лид помечается дублем (FAIL: ожидаем 2 charge, получаем 1). + +- [ ] **Step 3: Убрать DuplicateDetector из `ProcessWebhookJob`** + +В `app/app/Jobs/ProcessWebhookJob.php`: +- Удалить `use App\Services\DuplicateDetector;`. +- Удалить `$duplicateDetector = app(DuplicateDetector::class);` и его передачу в `DB::transaction`. +- Удалить блок поиска master + ветку (строки ~119–133: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && ...) { $this->markAsDuplicate(...); return; }`). После проверки `wasRecentlyCreated` сразу `$this->chargeNewLead(...)`. +- Удалить приватный метод `markAsDuplicate(...)` (строки ~144–165). +- Обновить doc-комментарии (убрать абзац про Биз-19/DuplicateDetector). + +- [ ] **Step 4: Удалить сервис и дубль-тесты** + +```bash +rm app/app/Services/DuplicateDetector.php +``` +В `app/tests/Feature/ProcessWebhookJobTest.php` удалить тесты телефонного дедупа (master в 24ч → дубль / master старше 24ч / ActivityLog duplicate_of). Оставить/адаптировать только релевантные (vid-идемпотентность, zero-balance). + +- [ ] **Step 5: Прогон** + +Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php` +Expected: GREEN (включая новый тест из Step 1). + +- [ ] **Step 6: Verify — нет висячих ссылок на DuplicateDetector** + +Run: `grep -rn "DuplicateDetector\|findMaster\|markAsDuplicate" app/` +Expected: 0 совпадений. + +- [ ] **Step 7: Коммит** + +```bash +git add app/app/Jobs/ProcessWebhookJob.php app/tests/Feature/ProcessWebhookJobTest.php +git rm app/app/Services/DuplicateDetector.php +git commit -m "refactor(billing-v2): remove DuplicateDetector + phone dedup from ProcessWebhookJob (Spec B)" +``` + +--- + +## Task 6: Замок в `RouteSupplierLeadJob::createDealCopyForProject` + +**Files:** +- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` +- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` + +- [ ] **Step 1: Написать падающий тест замка (повторная выдача той же поставки клиенту)** + +Добавить в `SupplierLeadDeliveryGuardTest.php` (определить локальный 6-арговый `runRouteJob`-хелпер в этом файле, без `DuplicateDetector`): +```php +use App\Services\LeadRouter; +use App\Services\LeadDistributor; +use App\Services\NotificationService; +use App\Services\RegionTagResolver; +use App\Services\Billing\LedgerService; +use App\Services\SupplierProjects\SupplierProjectResolver; +use Illuminate\Support\Facades\DB; + +function runRouteJobB(int $id): void +{ + (new RouteSupplierLeadJob($id))->handle( + app(LeadRouter::class), + app(SupplierProjectResolver::class), + app(NotificationService::class), + app(LedgerService::class), + app(LeadDistributor::class), + app(RegionTagResolver::class), + ); +} + +it('lock: re-running same delivery to same tenant does not double-charge', function (): void { + $this->seed(PricingTierSeeder::class); + + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru', + ]); + $tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']); + $p = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, + ]); + linkProjectToSupplier($p, $sp); + + $vid = 610001; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, + 'phone' => '79991234567', + 'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], + ]); + + runRouteJobB($lead->id); + + // Сбросить processed_at, чтобы пройти мимо idempotency-guard и проверить ИМЕННО замок БД. + $lead->update(['processed_at' => null]); + runRouteJobB($lead->id); + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1); + expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + expect(DB::table('supplier_lead_deliveries') + ->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php --filter="re-running same delivery"` +Expected: FAIL (без замка второй прогон создаёт вторую сделку + второй charge). + +- [ ] **Step 3: Вставить замок в `createDealCopyForProject`** + +В `app/app/Jobs/RouteSupplierLeadJob.php`, внутри `DB::transaction` в `createDealCopyForProject`, ПОСЛЕ `SET LOCAL app.current_tenant_id`, lock'а tenant и recheck'а лимита проекта, но ДО `Deal::create`: +```php + // Spec B: замок «одна поставка одному клиенту = один раз». + // insertOrIgnore вернёт 0, если строка (supplier_lead_id, tenant_id) уже есть — + // эта поставка уже выдавалась этому клиенту (гонка / перезапуск / CSV). Без charge. + $locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([ + 'supplier_lead_id' => $lead->id, + 'tenant_id' => $tenant->id, + 'created_at' => now(), + ]); + if ($locked === 0) { + Log::info('supplier_lead.delivery_already_locked', [ + 'supplier_lead_id' => $lead->id, + 'tenant_id' => $tenant->id, + ]); + + return false; + } +``` +После `Deal::create([...])` добавить проставление `deal_id` в замок: +```php + DB::table('supplier_lead_deliveries') + ->where('supplier_lead_id', $lead->id) + ->where('tenant_id', $tenant->id) + ->update(['deal_id' => $deal->id]); +``` +NB: `insertOrIgnore` под RLS-политикой `tenant_isolation` — `app.current_tenant_id` уже выставлен в этой транзакции, WITH CHECK (= USING) пройдёт. + +- [ ] **Step 4: Прогон** + +Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` +Expected: PASS (все кейсы файла, включая Task 3 «2 проекта → 1 сделка»). + +- [ ] **Step 5: Коммит** + +```bash +git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php +git commit -m "feat(billing-v2): per-(delivery,tenant) lock guard in RouteSupplierLeadJob (Spec B)" +``` + +--- + +## Task 7: Тесты политики дублей (model-agnostic) + reconcile прочих сюит + +**Files:** +- Modify: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` +- Modify: затронутые тесты с `DuplicateDetector`/`runRouteJob` / `balance_leads`-долгом + +- [ ] **Step 1: Тест «два разных vid, один телефон, один клиент → оба charge»** + +Добавить в `SupplierLeadDeliveryGuardTest.php`: +```php +it('same phone, two different deliveries to one tenant → both charged', function (): void { + $this->seed(PricingTierSeeder::class); + + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru', + ]); + $tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']); + $p = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, + ]); + linkProjectToSupplier($p, $sp); + + foreach ([700001, 700002] as $vid) { + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, + 'phone' => '79991234567', + 'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], + ]); + runRouteJobB($lead->id); + } + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2); + expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2); +}); +``` + +- [ ] **Step 2: Тест «5 клиентов под источник → ровно 3 списания у 3 клиентов»** + +Добавить (сидируемый distributor для детерминизма, как в существующем cap-тесте): +```php +use Random\Engine\Mt19937; +use Random\Randomizer; + +it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged', function (): void { + $this->seed(PricingTierSeeder::class); + app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7)))); + + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru', + ]); + foreach (range(1, 5) as $i) { + $t = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']); + $p = Project::factory()->create([ + 'tenant_id' => $t->id, 'is_active' => true, + 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, + ]); + linkProjectToSupplier($p, $sp); + } + + $vid = 710001; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid, + 'phone' => '79991234567', + 'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()], + ]); + + runRouteJobB($lead->id); + + $lead->refresh(); + expect($lead->deals_created_count)->toBe(3); + expect(LeadCharge::query()->where('tier_no', '>=', 0)->count())->toBe(3); + // 3 разных клиента в замке. + expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3); + expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3); +}); +``` + +- [ ] **Step 3: Прогон файла** + +Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` +Expected: PASS все кейсы. + +- [ ] **Step 4: Reconcile прочих сюит, ломающихся сигнатурой/моделью** + +Найти все вызовы 7-арговой `handle()` и ссылки на DuplicateDetector: +```bash +grep -rln "DuplicateDetector\|app(DuplicateDetector" app/tests +``` +В каждом файле (`RouteSupplierLeadJobBillingTest.php`, `Integration/SupplierLeadFlowTest.php`, `AutoPauseFlowTest.php`, `Pd/DealCreatePdLogTest.php`, и т.п.): +- убрать `app(DuplicateDetector::class),` из вызовов `handle()` (→ 6 аргументов); +- убрать `use App\Services\DuplicateDetector;`; +- удалить/переписать кейсы, проверявшие телефонный дедуп. +Если эти тесты используют `balance_leads`-ассерты, несовместимые с always-rub (тест-долг Спека A) и попадают в зону правки — мигрировать на `balance_rub`/`LeadCharge` по образцу `RouteSupplierLeadJobBillingTest` rub-кейса. Тесты, которые мы не трогаем и которые были красны до Task 1, оставить как есть (вне scope Спека B; зафиксировать в отчёте). + +- [ ] **Step 5: Прогон затронутых сюит** + +Run: +```bash +php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Pd/DealCreatePdLogTest.php tests/Feature/Supplier/CsvReconcileJobTest.php +``` +Expected: GREEN (кроме явно задокументированного pre-existing `balance_leads`-долга, если решено его не трогать). + +- [ ] **Step 6: Коммит** + +```bash +git add app/tests +git commit -m "test(billing-v2): dup-policy tests (no phone dedup, per-client cap, lock) + signature reconcile" +``` + +--- + +## Task 8: Финальная регрессия + чистка + +**Files:** нет новых правок (verify). + +- [ ] **Step 1: Verify — нет `duplicate_detected` / `duplicate_of_id`-записи** + +Run: +```bash +grep -rn "duplicate_detected" app/ db/ # ожидать 0 +grep -rn "duplicate_of_id" app/app # ожидать 0 (колонка спящая, код не пишет) +``` +Expected: 0 совпадений в коде (комментарии/CHANGELOG допустимы). + +- [ ] **Step 2: DROP лишнего индекса (миграция + schema уже правлены в Task 2 Step 5)** + +Создать `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql`: +```sql +-- Индекс по deals(duplicate_of_id) больше не нужен — телефонный дедуп удалён (Spec B). +DROP INDEX IF EXISTS deals_duplicate_of_id_idx; +``` +NB: имя индекса автоген — уточнить: `grep -n "duplicate_of_id" db/schema.sql` + на dev `\di deals*` / `SELECT indexname FROM pg_indexes WHERE tablename='deals' AND indexdef ILIKE '%duplicate_of_id%'`. Подставить фактическое имя. +Создать парную `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` (паттерн как Task 2 Step 4, idempotent через `DROP INDEX IF EXISTS`; `up()` грузит .sql, `down()` — пусто или воссоздаёт индекс). Убедиться, что `CREATE INDEX ... deals (duplicate_of_id)` уже убран из `db/schema.sql` (Task 2 Step 5). + +- [ ] **Step 3: Линт/статика** + +Run: +```bash +composer pint +composer stan +``` +Expected: Pint clean; Larastan 0 новых ошибок (для baseline в worktree скопировать `_ide_helper*.php` из основного чекаута — квирк A1-tooling). + +- [ ] **Step 4: Полная backend-регрессия** + +Run: `php artisan test --parallel` +Expected: GREEN; кроме явно задокументированного pre-existing `balance_leads`-тест-долга Спека A, если он не входил в scope правок. Зафиксировать итог в отчёте. + +- [ ] **Step 5: Финальный коммит миграции индекса** + +```bash +git add db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql \ + app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php +git commit -m "chore(billing-v2): drop unused deals(duplicate_of_id) index (Spec B)" +``` + +--- + +## Self-Review (выполнено автором плана) + +- **Покрытие спека:** §3.1 убрать фильтр → Tasks 4,5; §3.2 раздача по клиентам → Task 3; §3.3 замок БД → Tasks 2,6; §3.4 чистка следов → Tasks 2 (индекс), 8 (verify; `duplicate_detected` отсутствует в base — подтверждено); §3.5 не трогаем (vid-идемпотентность/CSV-дедуп) → не затрагиваются; §4 крайние случаи → тесты Tasks 6,7; §5 тесты → Tasks 5,6,7; §6 выкатка одна-фазная + CHANGELOG → Task 2. +- **Плейсхолдеры:** код приведён для всех правок; имя индекса в Task 8 — единственное «уточнить прогоном» (автоген PG-имя, нельзя знать без БД — дана точная команда выяснения). +- **Согласованность типов:** `runRouteJobB` (6 арг, без DuplicateDetector) — единый хелпер новых тестов; `insertOrIgnore` возвращает int (кол-во вставленных); `LedgerService::chargeForDelivery` сигнатура неизменна; таблица `supplier_lead_deliveries` колонки совпадают между DDL, моделью и тестами. +- **Scope:** один связный план; pre-existing `balance_leads`-тест-долг Спека A явно вынесен как «вне scope, по решению — мигрировать только затронутое».