# Биллинг 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
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
    $cols = collect(DB::select(
        "SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
    ))->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
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // Idempotency: если schema.sql уже загружен (migrate:fresh), таблица есть — пропускаем.
        if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['supplier_lead_deliveries']) !== null) {
            return;
        }

        $sql = file_get_contents(base_path('../db/migrations/2026_05_23_200_supplier_lead_deliveries.sql'));
        if ($sql === false) {
            throw new RuntimeException('Migration SQL file not found.');
        }
        DB::unprepared($sql);
    }

    public function down(): void
    {
        DB::unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
    }
};
```

- [ ] **Step 5: Вставить CREATE TABLE в `db/schema.sql`**

Вставить блок из Step 3 (без комментария-шапки повторно — достаточно одного) в `db/schema.sql` сразу ПОСЛЕ блока `CREATE TABLE webhook_dedup_keys (...)` с его индексами/RLS (найти `grep -n "CREATE TABLE webhook_dedup_keys" db/schema.sql`). Обновить header-строку версии:

```
-- Версия: v8.33 (23.05.2026 — Billing v2 Spec B: +supplier_lead_deliveries замок поставка↔клиент; −индекс deals(duplicate_of_id))
```

- [ ] **Step 6: Запись в `db/CHANGELOG_schema.md`**

Добавить сверху списка изменений:

```markdown
## v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей

- **+таблица `supplier_lead_deliveries`** (PK `supplier_lead_id`+`tenant_id`, FK на `supplier_leads` ON DELETE CASCADE, `deal_id` без FK, RLS `tenant_isolation`). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути.
- **−индекс `deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** — концепция телефонного дедупа удалена (DuplicateDetector); колонка `deals.duplicate_of_id` оставлена спящей.
- Метрики: +1 таблица, −1 индекс. (Сверять с header schema.sql.)
```

- [ ] **Step 7: Создать Eloquent-модель**

Создать `app/app/Models/SupplierLeadDelivery.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Замок «поставка ↔ клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
 * Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
 */
class SupplierLeadDelivery extends Model
{
    public $incrementing = false;

    public $timestamps = false;

    protected $primaryKey = null;

    protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
}
```

- [ ] **Step 8: Пересоздать тестовую БД и прогнать schema-тест**

Run:

```bash
php artisan migrate:fresh --env=testing && php artisan partitions:create-months --env=testing
php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
```

Expected: PASS.

- [ ] **Step 9: Коммит**

```bash
git add db/migrations/2026_05_23_200_supplier_lead_deliveries.sql \
        app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php \
        db/schema.sql db/CHANGELOG_schema.md \
        app/app/Models/SupplierLeadDelivery.php \
        app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
git commit -m "feat(billing-v2): supplier_lead_deliveries lock table (Spec B)"
```

---

## Task 3: Раздача по клиентам (LeadRouter — один проект на клиента)

**Files:**

- Modify: `app/app/Services/LeadRouter.php`
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (добавить кейс)

- [ ] **Step 1: Написать падающий тест «один клиент, 2 проекта → 1 сделка»**

Добавить в `SupplierLeadDeliveryGuardTest.php` (хелперы `prepareSharingFlow` / `linkProjectToSupplier` — из `tests/Pest.php`; сверить сигнатуру по `RouteSupplierLeadJobBillingTest.php`):

```php
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Database\Seeders\PricingTierSeeder;

it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge', function (): void {
    $this->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<int, Project> $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, по решению — мигрировать только затронутое».
