# Переделка миграции проектов — План 2: входящее распределение лидов

> **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:** Переписать маршрутизацию входящего лида: cap=3 (лид уходит максимум 3 случайным недобравшим клиентам), eligibility — структурно через pivot `project_supplier_links` (без phone-фильтра по `region_mask`), регион сделки — из тега поставщика (`raw_payload['tag']` → `deals.subject_code`).

**Architecture:** `LeadRouter` фильтрует кандидатов запросом через pivot (заменяет `match($platform)` по FK-колонке), убирает PHP-фильтр `PhonePrefixService::phoneMatchesRegions`. Новый pure-сервис `LeadDistributor` отбирает ≤3 получателей через инъектируемый сидируемый `\Random\Randomizer` (детерминизм тестов). `RegionTagResolver` (зеркало `resources/js/constants/regions.ts`) резолвит имя субъекта из тега в код 1..89. `RouteSupplierLeadJob` оркеструет: matched → distributor (cap) → deal.subject_code из тега.

**Tech Stack:** PHP 8.3 / Laravel 13 / Eloquent / `\Random\Randomizer` (PHP 8.2 seedable RNG) / Pest 4.

**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.5 (часть распределения), §4.6, §4.4.

**Prereq:** План 1 (фундамент) исполнен — есть `project_supplier_links`, `supplier_projects.subject_code`, `deals.subject_code`, связи belongsToMany. **Зависимость от Плана 3 отсутствует:** в проде pivot пока заполнен только бэкофиллом (legacy-связи, subject_code=NULL) — LeadRouter работает и на них; per-субъект связи появятся в Плане 3. Формула заказа (`SupplierQuotaAllocator`) — в Плане 3 (с её вызывающим sync-джобом), НЕ здесь.

---

## Task 1: `RegionTagResolver` — тег субъекта → код 1..89

**Files:**

- Create: `app/app/Support/RussianRegions.php` (канонический словарь субъектов — зеркало `resources/js/constants/regions.ts`)
- Create: `app/app/Services/RegionTagResolver.php`
- Test: `app/tests/Unit/Services/RegionTagResolverTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Services\RegionTagResolver;
use App\Support\RussianRegions;

it('resolves subject name to code', function (): void {
    $r = new RegionTagResolver;
    expect($r->resolve('Москва'))->toBe(82)
        ->and($r->resolve('Санкт-Петербург'))->toBe(83)
        ->and($r->resolve('Республика Адыгея'))->toBe(1);
});

it('returns null for «РФ» pool tag, empty and unknown', function (): void {
    $r = new RegionTagResolver;
    expect($r->resolve('РФ'))->toBeNull()
        ->and($r->resolve(''))->toBeNull()
        ->and($r->resolve('Нарния'))->toBeNull();
});

it('canonical region map mirrors regions.ts — exactly 89 subjects', function (): void {
    expect(count(RussianRegions::CODE_TO_NAME))->toBe(89);
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=RegionTagResolverTest`
Expected: FAIL — классов нет.

- [ ] **Step 3: Создать `RussianRegions` (зеркало regions.ts, 89 субъектов)**

`app/app/Support/RussianRegions.php` — карта `code => name` для кодов **1..89** (sentinel 0 «Вся РФ» НЕ включаем). Источник истины — [resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts): скопировать ВСЕ 89 пар `{ code, name }` (code 1..89), сохранив точные названия (включая диакритику «Северная Осетия — Алания», «Республика Саха (Якутия)»).

```php
<?php

declare(strict_types=1);

namespace App\Support;

/**
 * Канонический справочник субъектов РФ (1..89) — PHP-зеркало
 * resources/js/constants/regions.ts (конституционный порядок, ст. 65).
 * Sentinel 0 «Вся РФ» не входит (= NULL subject_code / пустой regions).
 *
 * ВАЖНО: при правке regions.ts синхронно править этот файл (тест RegionTagResolverTest
 * «mirrors regions.ts — exactly 89» ловит расхождение по count, но не по именам —
 * сверять имена вручную при изменениях).
 */
final class RussianRegions
{
    /** @var array<int, string> code(1..89) => официальное имя субъекта */
    public const CODE_TO_NAME = [
        1 => 'Республика Адыгея',
        2 => 'Республика Алтай',
        // … скопировать коды 3..81 из regions.ts (республики/края/области) …
        82 => 'Москва',
        83 => 'Санкт-Петербург',
        84 => 'Севастополь',
        85 => 'Еврейская автономная область',
        86 => 'Ненецкий автономный округ',
        87 => 'Ханты-Мансийский автономный округ — Югра',
        88 => 'Чукотский автономный округ',
        89 => 'Ямало-Ненецкий автономный округ',
    ];

    /** @return array<string, int> name => code (обратный индекс) */
    public static function nameToCode(): array
    {
        return array_flip(self::CODE_TO_NAME);
    }
}
```

**NB исполнителю:** заполнить все 89 строк из regions.ts (в примере выше пропущены 3..81 — скопировать дословно). Тест Step 1 «exactly 89» провалится, если не все.

- [ ] **Step 4: Создать `RegionTagResolver`**

`app/app/Services/RegionTagResolver.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Support\RussianRegions;

/**
 * Резолвит регион-тег поставщика (raw_payload['tag'] = имя субъекта или «РФ»)
 * в код субъекта 1..89. «РФ»/пусто/неизвестно → null (пул «Вся РФ»/неизвестно).
 *
 * Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.4.
 */
final class RegionTagResolver
{
    public function resolve(string $tag): ?int
    {
        $tag = trim($tag);

        if ($tag === '' || $tag === 'РФ') {
            return null;
        }

        return RussianRegions::nameToCode()[$tag] ?? null;
    }
}
```

- [ ] **Step 5: Прогнать тест — зелёный**

Run: `php artisan test --filter=RegionTagResolverTest`
Expected: PASS (3). Полный вывод. (Если «exactly 89» падает — не все субъекты скопированы из regions.ts.)

- [ ] **Step 6: pint + larastan**

Run: `composer pint -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php; composer stan`
Expected: 0 ошибок.

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

```bash
git add -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php app/tests/Unit/Services/RegionTagResolverTest.php
git commit -m "feat(supplier): RegionTagResolver + RussianRegions (subject name->code)" -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php app/tests/Unit/Services/RegionTagResolverTest.php
```

---

## Task 2: `LeadRouter` — eligibility через pivot, без phone-фильтра

**Files:**

- Modify: `app/app/Services/LeadRouter.php` (весь класс — убрать `PhonePrefixService`, переписать запрос на pivot, сигнатура `matchEligibleProjects` без `$phone`)
- Modify: `app/tests/Feature/Services/LeadRouterTest.php` (region-filter кейсы → pivot-кейсы; вызовы без `$phone`)
- Modify: `app/tests/Feature/Supplier/SupplierConnectionTest.php:100` (вызов без `$phone`)

- [ ] **Step 1: Переписать тест `LeadRouterTest` под pivot**

Заменить region-filter кейсы (include/exclude по `region_mask`) на eligibility-через-pivot. Базовый каркас (адаптировать существующие helper'ы фабрик из текущего файла; ключевая смена — кандидат входит, только если есть строка pivot на этот supplier_project):

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Support\Facades\DB;

function linkProjectToSupplier(Project $project, SupplierProject $sp): void
{
    DB::table('project_supplier_links')->insert([
        'project_id' => $project->id,
        'supplier_project_id' => $sp->id,
        'platform' => $sp->platform,
        'subject_code' => $sp->subject_code,
    ]);
}

it('returns project linked via pivot to the supplier_project', function (): void {
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $sp = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id, 'is_active' => true,
        'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
    ]);
    linkProjectToSupplier($project, $sp);

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(1)
        ->and($matched->first()->id)->toBe($project->id);
});

it('excludes project NOT linked to this supplier_project', function (): void {
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $sp = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r2.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'is_active' => true,
        'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
    ]); // не линкуем

    expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
});

it('excludes inactive project, project at limit, and zero-balance tenant', function (): void {
    $sp = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r3.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    $t1 = Tenant::factory()->create(['balance_leads' => 100]);
    $inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
    linkProjectToSupplier($inactive, $sp);

    $atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
    linkProjectToSupplier($atLimit, $sp);

    $t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
    $broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
    linkProjectToSupplier($broke, $sp);

    expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
});
```

(Сохранить из старого файла прочие нерегиональные кейсы, заменив вызовы `matchEligibleProjects($supplier, '799...')` → `matchEligibleProjects($supplier)` и добавив `linkProjectToSupplier()` где проект должен быть eligible. Региональные кейсы include/exclude по `region_mask` — **удалить** (фильтр снят).)

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=LeadRouterTest`
Expected: FAIL — сигнатура ещё с `$phone` + фильтр по region даёт другой результат / pivot не учитывается.

- [ ] **Step 3: Переписать `LeadRouter`**

`app/app/Services/LeadRouter.php` — полностью:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

/**
 * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
 *
 * Eligibility — структурно через pivot project_supplier_links: проект eligible,
 * если связан с пришедшим supplier_project (= источник × субъект) + активен +
 * сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
 *
 * Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
 * фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
 * гарантирован тем, через какой supplier_project пришёл лид.
 *
 * Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
 * sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
 *
 * Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
 */
class LeadRouter
{
    /**
     * @return Collection<int, Project>
     */
    public function matchEligibleProjects(SupplierProject $supplierProject): Collection
    {
        // МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
        $todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);

        /** @var Collection<int, Project> $candidates */
        $candidates = Project::on('pgsql_supplier')
            ->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('created_at')
            ->orderBy('id')
            ->get();

        return $candidates->values();
    }
}
```

- [ ] **Step 4: Обновить прочих вызывающих (drop `$phone`)**

- `app/tests/Feature/Supplier/SupplierConnectionTest.php:100`: `matchEligibleProjects($supplier, '79991234567')` → `matchEligibleProjects($supplier)` (+ убедиться, что проект там линкуется через pivot, иначе ожидание скорректировать).
- `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php:489`: mock `shouldReceive('matchEligibleProjects')->with(...)` — снять аргумент phone из `with()` (если задан), вернуть Collection как раньше.

- [ ] **Step 5: Прогнать LeadRouter + связанные**

Run: `php artisan test --filter='LeadRouterTest|SupplierConnectionTest'`
Expected: PASS. Полный вывод; любой failed — с file:line.

- [ ] **Step 6: pint + larastan**

Run: `composer pint -- app/app/Services/LeadRouter.php; composer stan`
Expected: 0 ошибок. (`PhonePrefixService` остаётся в проекте — его юзает `ProjectService` dual-write; `PhonePrefixServiceTest` не трогаем.)

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

```bash
git add -- app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php app/tests/Feature/Supplier/SupplierConnectionTest.php
git commit -m "feat(supplier): LeadRouter eligibility via pivot, drop phone region filter" -- app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php app/tests/Feature/Supplier/SupplierConnectionTest.php
```

---

## Task 3: `LeadDistributor` — cap=3 случайных получателя (детерминируемо)

**Files:**

- Create: `app/app/Services/LeadDistributor.php`
- Test: `app/tests/Unit/Services/LeadDistributorTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Services\LeadDistributor;
use Illuminate\Support\Collection;
use Random\Engine\Mt19937;
use Random\Randomizer;

function projects(int $n): Collection
{
    return collect(range(1, $n))->map(fn (int $i) => (object) ['id' => $i]);
}

it('returns all when eligible count <= cap (3)', function (): void {
    $d = new LeadDistributor;
    expect($d->selectRecipients(projects(2)))->toHaveCount(2)
        ->and($d->selectRecipients(projects(3)))->toHaveCount(3);
});

it('caps at 3 when more eligible', function (): void {
    $d = new LeadDistributor;
    expect($d->selectRecipients(projects(7)))->toHaveCount(3);
});

it('selection is a subset of eligible and deterministic under seeded RNG', function (): void {
    $eligible = projects(7);
    $d = new LeadDistributor(new Randomizer(new Mt19937(42)));
    $picked = $d->selectRecipients($eligible)->pluck('id')->all();

    expect($picked)->toHaveCount(3)
        ->and(collect($picked)->every(fn ($id) => $id >= 1 && $id <= 7))->toBeTrue();

    // тот же seed → тот же выбор
    $d2 = new LeadDistributor(new Randomizer(new Mt19937(42)));
    expect($d2->selectRecipients($eligible)->pluck('id')->all())->toBe($picked);
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=LeadDistributorTest`
Expected: FAIL — класса нет.

- [ ] **Step 3: Создать `LeadDistributor`**

`app/app/Services/LeadDistributor.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Support\Collection;
use Random\Randomizer;

/**
 * Отбор получателей входящего лида: ≤ CAP случайных из eligible (sharing cap).
 *
 * cap=3 — защита владельца номера-донора (лид продаётся максимум 3 раза).
 * Eligible уже отфильтрован LeadRouter (есть остаток лимита) → отбор лимит не
 * превышает. Рандом через инъектируемый \Random\Randomizer (тесты сидируют
 * Mt19937 для детерминизма; прод — CSPRNG по умолчанию).
 *
 * Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.6.
 */
final class LeadDistributor
{
    public const CAP = 3;

    public function __construct(private readonly Randomizer $randomizer = new Randomizer) {}

    /**
     * @template T
     * @param  Collection<int, T>  $eligible
     * @return Collection<int, T>
     */
    public function selectRecipients(Collection $eligible): Collection
    {
        $items = $eligible->values()->all();

        if (count($items) <= self::CAP) {
            return collect($items);
        }

        $keys = $this->randomizer->pickArrayKeys($items, self::CAP);

        return collect($keys)->map(fn (int $k) => $items[$k])->values();
    }
}
```

- [ ] **Step 4: Прогнать тест — зелёный**

Run: `php artisan test --filter=LeadDistributorTest`
Expected: PASS (3). Полный вывод.

- [ ] **Step 5: pint + larastan**

Run: `composer pint -- app/app/Services/LeadDistributor.php; composer stan`
Expected: 0 ошибок.

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

```bash
git add -- app/app/Services/LeadDistributor.php app/tests/Unit/Services/LeadDistributorTest.php
git commit -m "feat(supplier): LeadDistributor cap=3 seedable random selection" -- app/app/Services/LeadDistributor.php app/tests/Unit/Services/LeadDistributorTest.php
```

---

## Task 4: `RouteSupplierLeadJob` — cap=3 + регион в deal

**Files:**

- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` (handle: distributor; createDealCopyForProject: deal.subject_code из тега)
- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (cap=3 кейс, mock matchEligibleProjects без phone, seeded LeadDistributor bind)

- [ ] **Step 1: Написать падающий тест (cap=3 + регион)**

Добавить в `RouteSupplierLeadJobTest` (адаптировать существующие фабрики/helpers файла):

```php
it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
    // seeded distributor — детерминизм
    $this->app->bind(\App\Services\LeadDistributor::class, fn () => new \App\Services\LeadDistributor(
        new \Random\Randomizer(new \Random\Engine\Mt19937(7))
    ));

    $sp = \App\Models\SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    // 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
    foreach (range(1, 5) as $i) {
        $t = \App\Models\Tenant::factory()->create(['balance_leads' => 100]);
        $p = \App\Models\Project::factory()->create([
            'tenant_id' => $t->id, 'is_active' => true,
            'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
        ]);
        \Illuminate\Support\Facades\DB::table('project_supplier_links')->insert([
            'project_id' => $p->id, 'supplier_project_id' => $sp->id, 'platform' => 'B1', 'subject_code' => 82,
        ]);
    }

    $lead = \App\Models\SupplierLead::factory()->create([
        'phone' => '79991234567',
        'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
        'processed_at' => null,
    ]);

    (new \App\Jobs\RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
        app(\App\Services\LeadDistributor::class),
        app(\App\Services\RegionTagResolver::class),
    );

    $deals = \App\Models\Deal::query()->where('source_crm_id', 555111)->get();
    expect($deals)->toHaveCount(3)
        ->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter='RouteSupplierLeadJobTest'`
Expected: FAIL — handle() ещё без `LeadDistributor`/`RegionTagResolver` параметров; создаётся 5 deal'ов вместо 3; subject_code не пишется.

- [ ] **Step 3: Внести изменения в `RouteSupplierLeadJob`**

a) Импорты: добавить `use App\Services\LeadDistributor;` и `use App\Services\RegionTagResolver;`.

b) Сигнатура `handle()` — добавить два параметра в конец:

```php
    public function handle(
        LeadRouter $router,
        SupplierProjectResolver $resolver,
        DuplicateDetector $duplicateDetector,
        NotificationService $notifier,
        LedgerService $ledger,
        LeadDistributor $distributor,
        RegionTagResolver $tagResolver,
    ): void {
```

c) Заменить блок matched→loop ([RouteSupplierLeadJob.php:111-133](../../../app/app/Jobs/RouteSupplierLeadJob.php#L111)):

```php
        $matched = $router->matchEligibleProjects($supplier);
        $selected = $distributor->selectRecipients($matched); // cap=3 случайных

        $subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));

        $createdCount = 0;
        $failures = [];
        foreach ($selected as $project) {
            try {
                if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
                    $createdCount++;
                }
            } catch (Throwable $e) {
                $failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
                Log::warning('supplier_lead.per_project_routing_failed', [
                    'supplier_lead_id' => $lead->id,
                    'project_id' => $project->id,
                    'tenant_id' => $project->tenant_id,
                    'exception' => $e->getMessage(),
                ]);
            }
        }

        if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) {
            throw new RuntimeException(
                'All eligible projects failed routing for supplier_lead='.$lead->id.
                '; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
            );
        }
```

d) `createDealCopyForProject` — добавить параметр `?int $subjectCode` и писать его в Deal::create:

```php
    private function createDealCopyForProject(
        SupplierLead $lead,
        Project $project,
        DuplicateDetector $duplicateDetector,
        NotificationService $notifier,
        LedgerService $ledger,
        ?int $subjectCode,
    ): bool {
```

В `Deal::create([...])` ([RouteSupplierLeadJob.php:247](../../../app/app/Jobs/RouteSupplierLeadJob.php#L247)) добавить строку `'subject_code' => $subjectCode,` (рядом с `'status' => 'new',`).

- [ ] **Step 4: Прогнать тест cap+регион — зелёный**

Run: `php artisan test --filter='RouteSupplierLeadJobTest'`
Expected: PASS (новый кейс + существующие). Полный вывод; failed — с file:line. (Существующие кейсы с ≤3 eligible не затронуты — distributor вернёт всех.)

- [ ] **Step 5: pint + larastan**

Run: `composer pint -- app/app/Jobs/RouteSupplierLeadJob.php; composer stan`
Expected: 0 ошибок.

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

```bash
git add -- app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
git commit -m "feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag" -- app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
```

---

## Task 5: Регрессия распределения

**Files:** нет (проверка).

- [ ] **Step 1: Полный Supplier + Jobs + Integration suite**

Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Jobs app/tests/Feature/Services app/tests/Unit/Services`
Expected: GREEN. Выписать passed/failed; любой failed — file:line. Особое внимание `AutoPauseFlowTest` (зависит от matchEligibleProjects is_active фильтра — он сохранён).

- [ ] **Step 2: pint + larastan по всему дереву**

Run: `composer pint; composer stan`
Expected: 0 ошибок.

- [ ] **Step 3 (verification-before-completion):** перед заявлением «План 2 готов» — invoke `superpowers:verification-before-completion`; привести фактический вывод тестов, не «должно проходить».

---

## Self-review (выполнен при написании плана)

- **Покрытие spec §4.5/§4.6/§4.4:** pivot-eligibility + снятие phone-фильтра (T2); cap=3 рандом (T3) + интеграция в job (T4); регион→deals.subject_code из тега (T1+T4). Формула заказа §4.5 (заказ) — в Плане 3 (с вызывающим sync-джобом), отмечено в prereq.
- **Без плейсхолдеров:** код приведён; единственное «дозаполнить» — 89 субъектов в `RussianRegions` из существующего regions.ts (концертный источник в репо + тест «exactly 89» как гейт; не TODO).
- **Согласованность типов:** `matchEligibleProjects(SupplierProject): Collection` — единая новая сигнатура во всех вызовах (T2 Step 4 перечисляет всех вызывающих); `selectRecipients(Collection): Collection`; `RegionTagResolver::resolve(string): ?int`; `deals.subject_code` int — совпадает с Планом 1 Task 3.
- **Риск:** `Randomizer::pickArrayKeys` доступен с PHP 8.2 (у нас 8.3 ✓). Детерминизм в job-тесте — через container-bind seeded `LeadDistributor`.
