Files
portal/docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-2-distribution.md
T
Дмитрий 41a3ce5e80 docs(plan): Plan 2 (входящее распределение) — переделка миграции проектов
5 TDD-задач: RegionTagResolver (тег субъекта -> код, зеркало regions.ts),
LeadRouter на pivot без phone-фильтра, LeadDistributor cap=3 (seedable RNG),
RouteSupplierLeadJob (cap + deal.subject_code из тега), регрессия.
Второй из 4 планов эпика. +cspell. Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:22 +03:00

30 KiB
Raw Blame History

Переделка миграции проектов — План 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 §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

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: скопировать ВСЕ 89 пар { code, name } (code 1..89), сохранив точные названия (включая диакритику «Северная Осетия — Алания», «Республика Саха (Якутия)»).

<?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

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: Коммит
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

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

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: Коммит
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

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

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: Коммит
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 файла):

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() — добавить два параметра в конец:

    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):

        $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:

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

В Deal::create([...]) (RouteSupplierLeadJob.php:247) добавить строку '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: Коммит
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.