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>
30 KiB
Переделка миграции проектов — План 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: mockshouldReceive('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_codeint — совпадает с Планом 1 Task 3. - Риск:
Randomizer::pickArrayKeysдоступен с PHP 8.2 (у нас 8.3 ✓). Детерминизм в job-тесте — через container-bind seededLeadDistributor.