Compare commits

..

2 Commits

Author SHA1 Message Date
Дмитрий f172e2a580 feat(router-gate): SAFE_EXACT +Laravel dev workflow
Closes design gap in v4 whitelist: dev commands (pest, composer test/pint/stan/insights/rector,
php artisan test/migrate variants/db:seed/cache:clear etc., vendor/bin/pest) were falling into
default-deny. That blocked sessions working on app/ code and pushed controllers toward override
phrases or requests to disable the defense.

Changes are surgical and do not weaken discipline defense:
- 4 new SAFE_EXACT regex entries for specific dev commands
- tinker EXCLUDED on purpose (REPL = arbitrary PHP exec risk)
- migrate:install and other unknown migrate subcommands stay blocked via
  lookahead instead of word-boundary (precision fix)
- Hard-blacklist for mutating package operations, chain-semantics C13,
  file-watcher, TDD-gate, path-deny, coverage requirement and the other 15
  defense hooks are NOT touched.

TDD: 22 RED allow-tests + 7 still-block tests + 3 regression tests.
Full tools-only regression 1821/1821 GREEN.

Live smoke verified: composer test allowed; migrate:install blocked.

Whitelist v3.8 was sized around vitest tools-only; Laravel app/ dev workflow
slipped through. This commit corrects that without touching the architecture.
2026-05-30 16:11:34 +03:00
Дмитрий 4686b36571 docs(region): lead-region-resolution spec v0.5 + 6-session plan 2026-05-30 15:38:54 +03:00
4 changed files with 1783 additions and 0 deletions
@@ -0,0 +1,641 @@
# Lead Region Resolution — Master Implementation Plan
> **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.
>
> **This is a MASTER plan split into 6 sessions.** Each session is a self-contained, testable deliverable. Execute sessions **in order** (later sessions depend on earlier ones). Each session = one subagent-driven-development run with its own review checkpoints. Before starting a session, re-read this header + the session's "Preconditions".
**Goal:** Резолвить настоящий регион лида по телефону (DaData → Россвязь → tag-fallback) и переключить `LeadRouter` на каскадную маршрутизацию по региону, чтобы клиенты, делящие один источник с разными regions, получали только лиды своего региона.
**Architecture:** Новый сервис `LeadRegionResolver` вызывается в `RouteSupplierLeadJob::handle()` ДО транзакционного цикла, резолвит `subject_code` + оператора по телефону, персистит в `supplier_leads` + `lead_region_resolution_log`. `LeadRouter::matchEligibleProjects` получает новый параметр `?int $resolvedSubjectCode` и фильтрует кандидатов в 3 фазы (точное совпадение региона → «вся РФ» → запасной канал с подменой). Локальный реестр Россвязи (`phone_ranges`) — fallback когда DaData недоступна/неуверена.
**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16 (партиции, RLS, `INT[]`), Pest 4, Redis (кэш + token-bucket), DaData REST API (`cleaner.dadata.ru/api/v1/clean/phone`).
**Source spec:** [docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md](../specs/2026-05-29-lead-region-resolution-design.md) v0.5. Прочитать целиком перед стартом — этот план не дублирует §3-§12 спеки, а превращает их в исполнимые шаги.
---
## ⚠️ КРИТИЧЕСКИЕ ПОПРАВКИ К СПЕКЕ (читать ДО любого кода)
Эти расхождения спеки с фактическим кодом обнаружены прямым code-walking 30.05.2026. Implementer ОБЯЗАН следовать факту, а не цифрам/именам из спеки.
1. **Коды субъектов — НЕ автомобильные.** Спека §3.4.1 пишет «77 Москва, 50 МО, 78 СПб, 47 ЛО» — это НЕВЕРНО. Источник истины — [`app/app/Support/RussianRegions.php`](../../../app/app/Support/RussianRegions.php) `CODE_TO_NAME` (конституционный порядок ст. 65, 1..89):
- **Москва = 82**, **Санкт-Петербург = 83**, **Московская область = 56**, **Ленинградская область = 53**.
- Севастополь = 84, Республика Крым = 13.
- Везде в коде/тестах/маппингах использовать ЭТИ коды.
2. **`RussianRegions` НЕ имеет `codeToName()`-метода.** Есть только `public const CODE_TO_NAME` (массив) и `public static function nameToCode(): array` (через `array_flip`). Если нужен code→name — читать константу `RussianRegions::CODE_TO_NAME[$code]`.
3. **`LeadRouter::matchEligibleProjects` имеет ДВА SQL-пути** — `DIRECT` (по `signal_type` + `unique_key`) и `B1/B2/B3` (через `project_supplier_links` pivot). Каскад (§3.9) спека показывает только для pivot-пути — **реализовать каскад для ОБОИХ путей**.
4. **`project_routing_snapshots` УЖЕ содержит `regions INT[] NOT NULL DEFAULT '{}'`** (миграция `2026_05_27_120000`). Колонку добавлять НЕ нужно — каскадный WHERE ложится на готовую колонку через `?::int = ANY(snap.regions)` и `snap.regions = '{}'::int[]`.
5. **`LeadDistributor::selectRecipients` сейчас берёт cap=3 СЛУЧАЙНО.** Каскад спеки требует упорядоченный отбор (точное → РФ → запасной, сортировка по остатку лимита DESC) внутри роутера. Реконсиляция: роутер сам обрезает до 3 упорядоченно → `LeadDistributor` при `count ≤ CAP` возвращает коллекцию как есть (без шаффла, строка 36-38). Это **смена поведения** (random → детерминированный по остатку лимита). Зафиксировано как сознательное решение — см. §«Открытый вопрос D1» ниже. НЕ менять `LeadDistributor`; роутер просто отдаёт ≤3.
6. **`subject_code` пишется в `deals` уже сейчас** (Job строка 405-406, через `?int $subjectCode` из `RegionTagResolver`). Интеграция — заменить источник, не добавить колонку. `deals.subject_code` уже существует (миграция `2026_05_20_102000`).
7. **Команда запуска тестов:** из каталога `app/`. Один файл: `cd app && ./vendor/bin/pest tests/Unit/Services/LeadRegionResolverTest.php`. Фильтр по имени: `cd app && ./vendor/bin/pest --filter="dadata qc 0"`. Полный прогон сервиса перед коммитом сессии. **NB Bash cwd persists** — всегда префиксить `cd app &&` или использовать subshell.
---
## Открытые вопросы для заказчика (решить ДО Session 5-6)
- **D1 (поведение распределения):** Сейчас при >3 кандидатах лид раздаётся 3 СЛУЧАЙНЫМ клиентам. Новый каскад раздаёт 3 клиентам с НАИБОЛЬШИМ остатком дневного лимита (детерминированно). Это значит: клиент с большим остатком лимита систематически получает больше лидов, чем клиент с малым. Спека §3.9 явно выбрала «сортировка по остатку DESC». **Подтвердить, что random-распределение можно убрать.** (Если заказчик хочет сохранить случайность внутри региона — это +1 задача: random-shuffle внутри каждой фазы перед cap.)
- **D2 (ambiguous-list staging):** Список «объединённых» регионов DaData (`'Санкт-Петербург и область'`, `'Москва и область'`) расширяется только по реальным наблюдениям на staging (спека §3.4.1). На старте — ровно эти 2 строки. Подтверждается smoke-прогоном (Session 6).
---
## Общие конвенции (применять во ВСЕХ сессиях)
### Тестовый сетап (Pest 4)
- **Unit-тесты** (`app/tests/Unit/...`): чистые, без БД где возможно; `Http::fake()` для DaData; `Cache::fake()`/`Cache::store('array')` для кэша.
- **Feature-тесты** (`app/tests/Feature/...`): `uses(DatabaseTransactions::class)` + `uses(Tests\Concerns\SharesSupplierPdo::class)`. Tenant-контекст: `DB::statement("SELECT set_config('app.current_tenant_id', '0', true)")` в `beforeEach` (как [`LeadRouterTest.php`](../../../app/tests/Feature/Services/LeadRouterTest.php)).
- Фабрики: `Tenant::factory()`, `Project::factory()`, `SupplierProject::factory()`/`::query()->create([...])`, `SupplierLead::factory()`.
- Хелперы (в [`app/tests/Pest.php`](../../../app/tests/Pest.php)): `linkProjectToSupplier($project, $supplier)`, `createRoutingSnapshotFromProject($project, ...)`**последний расширяется в Session 5** (добавить `string $regions = '{}'` параметр).
- Pest-стиль: `it('...', function () { ... })`, `expect($x)->toBe(...)`. Никакого PHPUnit class-стиля в новых тестах.
### Паттерн миграции (raw SQL, образец — `2026_05_27_120000_create_project_routing_snapshots_table.php`)
```php
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// SET ROLE crm_migrator на проде; на dev/testing — fallback postgres superuser.
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) { DB::statement('RESET ROLE'); }
} catch (\Throwable) { /* окружение без роли — продолжаем как superuser */ }
DB::unprepared(<<<'SQL'
-- DDL здесь
SQL);
}
public function down(): void
{
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) { DB::statement('RESET ROLE'); }
} catch (\Throwable) {}
DB::statement('DROP TABLE IF EXISTS <table> CASCADE');
}
};
```
- GRANT'ы: SaaS-level read-таблицы → `crm_readonly` + `crm_supplier_worker` SELECT; запись через `crm_migrator`. Tenant-таблицы → RLS policy + GRANT `crm_app_user`/`crm_supplier_worker` (образец snapshot-миграции строки 49-55).
- Партиционированные таблицы: явный `CREATE TABLE ..._y2026_m05 PARTITION OF ...` для текущего+следующего месяца + регистрация retention в `system_settings` (образец строки 57-78).
- **`db/schema.sql` + `db/CHANGELOG_schema.md`** обновлять при каждой схемной правке (правило §4.2 / §5 п.8 CLAUDE.md). Bump версии schema в header.
### Git / коммиты
- Ветка: `feat/lead-region-resolution` (создаётся в Session 1, см. Preconditions).
- Частые атомарные коммиты (per task). Conventional commits: `feat(region):`, `test(region):`, `chore(region):`.
- Каждая сессия завершается зелёной регрессией затронутого слоя + push.
---
## SESSION 1 — Схема БД + регистрация партиций
**Deliverable:** Все таблицы и колонки фичи существуют, миграция up/down работает, партиции регистрируются. Никакой бизнес-логики.
**Preconditions:** Чистый `main` (или согласованная база). Создать ветку: `git switch -c feat/lead-region-resolution`. Закоммитить spec (untracked) первым коммитом.
**Files:**
- Create: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
- Modify: `app/app/Services/MonthlyPartitionManager.php:48-62` (PARTITIONED_TABLES map)
- Modify: `db/schema.sql` (новые таблицы + ALTER, bump версии) + `db/CHANGELOG_schema.md`
- Test: `app/tests/Feature/Migrations/PhoneRangesMigrationTest.php`
### Task 1.1 — Failing test: миграция создаёт таблицы и колонки
- [ ] **Step 1: Написать падающий тест**
`app/tests/Feature/Migrations/PhoneRangesMigrationTest.php`:
```php
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(SharesSupplierPdo::class);
it('creates phone_ranges with lookup index', function (): void {
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->t)->not->toBeNull();
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='phone_ranges'"))
->pluck('column_name')->all();
expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
});
it('creates lead_region_resolution_log as partitioned table', function (): void {
$p = DB::selectOne("SELECT partattrs FROM pg_partitioned_table pt JOIN pg_class c ON c.oid=pt.partrelid WHERE c.relname='lead_region_resolution_log'");
expect($p)->not->toBeNull();
});
it('adds resolution columns to supplier_leads and deals', function (): void {
$sl = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='supplier_leads'"))->pluck('column_name')->all();
expect($sl)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
$d = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='deals'"))->pluck('column_name')->all();
expect($d)->toContain('phone_operator', 'region_substituted');
});
```
- [ ] **Step 2: Прогнать — убедиться что падает** (`cd app && ./vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php` → FAIL: relation does not exist)
- [ ] **Step 3: Написать миграцию.** DDL по спеке §4.1-§4.6 с поправками. Полный DDL (вставить в `DB::unprepared`):
```sql
-- 1. phone_ranges_imports (журнал импортов — создаём ПЕРВЫМ, на него FK)
CREATE TABLE phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
);
-- 2. phone_ranges (реестр Россвязи, SaaS-level без RLS)
CREATE TABLE phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_readonly, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
CREATE TABLE lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
phone_operator TEXT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_readonly;
CREATE TABLE lead_region_resolution_log_y2026_m05 PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06 PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- 4. supplier_leads +4 колонки (persistent idempotency + denormalized display)
ALTER TABLE supplier_leads
ADD COLUMN resolved_subject_code SMALLINT CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
ADD COLUMN region_source TEXT CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
ADD COLUMN dadata_qc SMALLINT,
ADD COLUMN phone_operator TEXT;
-- 5. deals +2 колонки
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
```
В том же `up()` после `DB::unprepared`: зарегистрировать retention `lead_region_resolution_log` в `system_settings` (паттерн snapshot-миграции строки 67-78, `value => '12'`, 365 дней). `down()`: `DROP TABLE IF EXISTS lead_region_resolution_log, phone_ranges, phone_ranges_imports CASCADE` + `ALTER TABLE ... DROP COLUMN IF EXISTS ...` для supplier_leads/deals + удалить system_settings ключ.
> **Гайд по партициям:** новый партиционированный `lead_region_resolution_log` имеет ключ `received_at` (как `deals`). Партиции `deals` создаются помесячно — наши партиции на старте только m05/m06, дальше их подхватит `partitions:create-months` ПОСЛЕ регистрации в Task 1.2.
- [ ] **Step 4: Прогнать тест — PASS** (`cd app && ./vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php`)
- [ ] **Step 5: Коммит** `git add -A && git commit -m "feat(region): schema — phone_ranges, resolution_log, supplier_leads/deals columns"`
### Task 1.2 — Регистрация новой партиц-таблицы в MonthlyPartitionManager
- [ ] **Step 1: Падающий тест** `app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`:
```php
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
it('knows lead_region_resolution_log partition key', function (): void {
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
});
```
- [ ] **Step 2: Прогнать — FAIL.**
- [ ] **Step 3: Добавить** в `MonthlyPartitionManager::PARTITIONED_TABLES` (после строки 61) `'lead_region_resolution_log' => 'received_at',`.
- [ ] **Step 4: Прогнать — PASS.**
- [ ] **Step 5: Коммит** `chore(region): register lead_region_resolution_log in MonthlyPartitionManager`.
### Task 1.3 — Синхронизация db/schema.sql + CHANGELOG
- [ ] **Step 1:** Добавить новые `CREATE TABLE`/`ALTER` в `db/schema.sql` (зеркало миграции), bump версии в header.
- [ ] **Step 2:** Запись в `db/CHANGELOG_schema.md` (новая версия, перечень изменений).
- [ ] **Step 3:** Коммит `chore(region): sync db/schema.sql + CHANGELOG for region resolution`.
**Session 1 завершение:** прогон `cd app && ./vendor/bin/pest tests/Feature/Migrations tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php` → GREEN. Push.
---
## SESSION 2 — Россвязь: реестр + lookup
**Deliverable:** `RossvyazPrefixLookup` находит регион+оператора по телефону через `phone_ranges`; `phone-ranges:import` команда импортирует реестр.
**Preconditions:** Session 1 смержена/на ветке. Таблицы `phone_ranges*` существуют.
**Files:**
- Create: `app/app/Services/RossvyazPrefixLookup.php`, `app/app/Services/Dto/RossvyazRecord.php`
- Create: `app/app/Console/Commands/PhoneRangesImportCommand.php`
- Test: `app/tests/Unit/Services/RossvyazPrefixLookupTest.php`, `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
### Task 2.1 — RossvyazRecord DTO + Lookup (TDD)
- [ ] **Step 1: Падающие тесты** `RossvyazPrefixLookupTest.php` (Feature, нужна БД — `uses(DatabaseTransactions::class, SharesSupplierPdo::class)`; сидируем `phone_ranges` напрямую через `DB::table`):
```php
it('mobile prefix returns correct region and operator', function (): void {
DB::table('phone_ranges')->insert([
'def_code'=>921,'from_num'=>5550000,'to_num'=>5559999,'operator'=>'МегаФон',
'region'=>'Санкт-Петербург','subject_code'=>83,'imported_at'=>now(),'import_id'=>seedImport(),
]);
$rec = app(App\Services\RossvyazPrefixLookup::class)->find('7921555XXXX');
expect($rec)->not->toBeNull()->and($rec->subjectCode)->toBe(83)->and($rec->region)->toBe('Санкт-Петербург');
});
it('prefers narrower range when two ranges overlap', function (): void { /* два диапазона, узкий выигрывает (ORDER BY to_num-from_num ASC) */ });
it('returns null for unknown prefix', function (): void {
expect(app(App\Services\RossvyazPrefixLookup::class)->find('7999XXXXXXX'))->toBeNull();
});
```
(`seedImport()` — локальный хелпер в тесте: вставляет строку `phone_ranges_imports` и возвращает id.)
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация.** `RossvyazRecord` — readonly DTO (`subjectCode: ?int`, `region: string`, `operator: string`). `RossvyazPrefixLookup::find(string $phone): ?RossvyazRecord` по алгоритму спеки §3.7: `def_code = (int) substr($phone,1,3)`, `subscriber = (int) substr($phone,4)`, SQL `SELECT region, operator, subject_code FROM phone_ranges WHERE def_code=? AND from_num<=? AND to_num>=? ORDER BY (to_num-from_num) ASC LIMIT 1`. Запрос через `DB::connection('pgsql_supplier')` (BYPASSRLS, как LeadRouter).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`.
### Task 2.2 — PhoneRangesImportCommand (TDD)
- [ ] **Step 1: Падающий Feature-тест**`phone-ranges:import --dry-run` парсит фикстурный XLSX/CSV в `phone_ranges_staging`, маппит region→subject_code через `RussianRegions::nameToCode()`, при `--dry-run` не свапает. (Фикстура: маленький CSV в `app/tests/Fixtures/rossvyaz/sample.csv`.)
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** по спеке §6.2: staging-таблица → COPY → checksum-idempotency → atomic `RENAME` swap → `phone_ranges_imports.status`. Несматчившиеся регионы → лог в `phone_ranges_imports.error`. `--dry-run` останавливается до swap. **NB:** реальный источник — пакет ~500-600 файлов XLSX (§6.1); для теста парсим один CSV-фикстуру. Парсер XLSX — отдельный приватный метод, в тесте подменяется CSV-веткой через флаг формата.
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): phone-ranges:import command with atomic swap + idempotency`.
**Session 2 завершение:** GREEN сервис-слой Россвязи. Push. (Реальный первый импорт реестра — оператором в Session 6 раскатке, не в тесте.)
---
## SESSION 3 — DaData клиент + бюджет + rate-limit + region map
**Deliverable:** `DaDataPhoneClient` дёргает REST, `DaDataRegionMap` маппит имя→код, `DaDataBudgetGuard` режет по дневному лимиту, token-bucket защищает от 429. Никакой оркестрации (она в Session 4).
**Preconditions:** Sessions 1-2 готовы.
**Files:**
- Create: `app/app/Services/DaData/DaDataPhoneClient.php`, `DaDataPhoneResponse.php`, `DaDataQualityCode.php`, `DaDataException.php`, `DaDataTimeoutException.php`
- Create: `app/app/Services/DaData/DaDataBudgetGuard.php`
- Create: `app/app/Support/DaDataRegionMap.php`
- Modify: `app/config/services.php` (+`dadata` блок)
- Test: `app/tests/Unit/Services/DaData/DaDataPhoneClientTest.php`, `DaDataBudgetGuardTest.php`, `app/tests/Unit/Support/DaDataRegionMapTest.php`
### Task 3.1 — config/services.php + DaDataQualityCode enum
- [ ] **Step 1:** Добавить в `config/services.php`:
```php
'dadata' => [
'api_key' => env('DADATA_API_KEY'),
'secret' => env('DADATA_SECRET'),
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
'retries' => (int) env('DADATA_RETRIES', 1),
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
],
```
- [ ] **Step 2:** `DaDataQualityCode` — enum:int (CASE_RECOGNIZED=0, ASSUMPTIONS=1, EMPTY=2, MULTIPLE=3, FOREIGN=7). Без теста (тривиальный enum) — покрывается через клиент.
- [ ] **Step 3: Коммит** `chore(region): config/services dadata + DaDataQualityCode enum`.
### Task 3.2 — DaDataRegionMap (TDD)
- [ ] **Step 1: Падающий unit-тест** `DaDataRegionMapTest.php`:
```php
use App\Support\DaDataRegionMap;
it('maps exact official names via RussianRegions', function (): void {
expect(DaDataRegionMap::toSubjectCode('Москва'))->toBe(82);
expect(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56);
expect(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83);
expect(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
});
it('flags ambiguous agglomeration strings', function (): void {
expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue();
expect(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue();
expect(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse();
});
it('returns null for unmappable region', function (): void {
expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull();
});
it('resolves all 89 RussianRegions names', function (): void {
foreach (App\Support\RussianRegions::CODE_TO_NAME as $code => $name) {
expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
}
});
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация.** `DaDataRegionMap`: `AMBIGUOUS_REGIONS = ['Санкт-Петербург и область','Москва и область']` (const). `OVERRIDES` — массив для несовпадающих имён (на старте пустой — заполняется findings). `toSubjectCode(string $name): ?int` → trim → `OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null`. `isAmbiguous(string $name): bool``in_array($name, self::AMBIGUOUS_REGIONS, true)`.
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): DaDataRegionMap with ambiguous-list + 89-region coverage`.
### Task 3.3 — DaDataPhoneClient (TDD, Http::fake)
> **Конвенция HTTP-клиента** — зеркалить [`app/app/Services/Supplier/SupplierPortalClient.php`](../../../app/app/Services/Supplier/SupplierPortalClient.php): инжектить `Illuminate\Http\Client\Factory $http`, кастомные исключения, приватный `request()`.
- [ ] **Step 1: Падающие unit-тесты** `DaDataPhoneClientTest.php` (по одному на qc 0/1/2/3/7 + timeout + 5xx-retry + 4xx-no-retry). Пример:
```php
use App\Services\DaData\DaDataPhoneClient;
use Illuminate\Support\Facades\Http;
it('parses qc=0 mobile response', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc'=>0,'qc_conflict'=>0,'type'=>'Мобильный','phone'=>'+7 921 555-12-34',
'provider'=>'МегаФон','region'=>'Санкт-Петербург и область','timezone'=>'UTC+3',
]], 200)]);
$resp = app(DaDataPhoneClient::class)->cleanPhone('7921555XXXX');
expect($resp->qc)->toBe(0)->and($resp->provider)->toBe('МегаФон')
->and($resp->region)->toBe('Санкт-Петербург и область');
});
it('throws DaDataTimeoutException on connection timeout', function (): void {
Http::fake(fn () => throw new Illuminate\Http\Client\ConnectionException('timeout'));
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('7921555XXXX'))
->toThrow(App\Services\DaData\DaDataTimeoutException::class);
});
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** по §3.6: POST `https://cleaner.dadata.ru/api/v1/clean/phone`, headers `Authorization: Token <key>`, `X-Secret: <secret>`, body `["<phone>"]`, timeout из config, retry на сетевые/5xx. Парсинг массива[0] → `DaDataPhoneResponse` (readonly DTO, поля по §3.6). `ConnectionException`/таймаут → `DaDataTimeoutException`; не-2xx после retry → `DaDataException`.
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): DaDataPhoneClient + DTO + exceptions`.
### Task 3.4 — DaDataBudgetGuard + token-bucket (TDD)
- [ ] **Step 1: Падающий тест**`canSpend()` true пока `phone_resolution.dadata.spent_today_kopecks < daily_cap`; false при превышении; `recordSpend()` делает Redis INCRBY. (`Cache::store('array')` или Redis-fake.)
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** §5.3 + §3.13: `DaDataBudgetGuard` (canSpend/recordSpend через Redis-ключ с дневным TTL). Token-bucket 18 RPS — `RateLimiter::for('dadata-cleaner', ...)` зарегистрировать в провайдере; в клиенте обернуть вызов (или отдельный guard — решить в Session 4 при сборке).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): DaDataBudgetGuard + rate-limit`.
**Session 3 завершение:** GREEN `tests/Unit/Services/DaData tests/Unit/Support/DaDataRegionMapTest.php`. Push.
---
## SESSION 4 — LeadRegionResolver (оркестратор)
**Deliverable:** `LeadRegionResolver::resolve(SupplierLead): RegionResolution` со всем каскадом qc-решений, кэшем, ambiguous-логикой, persistent-idempotency, cache-hit логированием. Это сердце фичи.
**Preconditions:** Sessions 1-3. Все суб-компоненты существуют и зелёные.
**Files:**
- Create: `app/app/Services/LeadRegionResolver.php`, `app/app/Services/Dto/RegionResolution.php`
- Test: `app/tests/Unit/Services/LeadRegionResolverTest.php` (12 кейсов из спеки §9.1)
### Task 4.1 — RegionResolution DTO + source rank
- [ ] **Step 1: Падающий тест** на DTO: поля `subjectCode: ?int`, `actualSubjectCode: ?int`, `source: string` ('dadata'|'rossvyaz'|'tag'|'unknown'), `phoneOperator: ?string`, `qc: ?int`, `cacheHit: bool`, `dadataResponseMasked: ?array`, `durationMs: ?int`, `rossvyazMatched: bool`. + статик `SOURCE_RANK` const `['dadata'=>4,'rossvyaz'=>3,'tag'=>2,'unknown'=>1]`. + фабрики `fromTag()`, `fromSupplierLead()` (для persistent-idempotency).
- [ ] **Step 2-4:** реализация readonly DTO, PASS.
- [ ] **Step 5: Коммит** `feat(region): RegionResolution DTO + SOURCE_RANK`.
### Task 4.2 — LeadRegionResolver: 12 кейсов (TDD, по одному тесту за раз)
Реализация по алгоритму спеки §3.3 + §3.4 (decision-таблица). Кэш-ключ `sha256("phone-region:".$phone)`, TTL = `config('services.dadata.cache_ttl_days')` дней. Persistent-idempotency: в начале `resolve()` если `$lead->resolved_subject_code !== null || $lead->region_source !== null``RegionResolution::fromSupplierLead($lead)` без DaData. Валидация телефона `/^7\d{10}$/` (как в Job/Controller).
Каждый тест из списка спеки §9.1 — отдельный TDD-цикл (Step write→fail→implement→pass→commit). Имена тестов (Pest `it('...')`):
- [ ] `dadata qc 0 returns dadata source``Http::fake` qc=0 region не-ambiguous → source='dadata', subjectCode маппится.
- [ ] `dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider` — region='Санкт-Петербург и область' → идём в Россвязь за subjectCode=83, provider остаётся от DaData (И-2). **Ключевой тест ambiguous-логики.**
- [ ] `dadata qc 3 returns dadata with multiple flag`.
- [ ] `dadata qc 1 falls back to rossvyaz`.
- [ ] `dadata qc 2 falls back to tag skipping rossvyaz`.
- [ ] `dadata qc 7 falls back to tag skipping rossvyaz`.
- [ ] `dadata timeout falls back to rossvyaz`.
- [ ] `dadata network error falls back to rossvyaz`.
- [ ] `budget cap exceeded skips dadata directly to rossvyaz` (`DaDataBudgetGuard::canSpend()` false).
- [ ] `cache hit skips dadata and rossvyaz` — второй вызов того же телефона не дёргает Http (assert `Http::assertSentCount`).
- [ ] `invalid phone skips dadata returns tag`.
- [ ] `qc 0 region null falls through to rossvyaz` (мобильный без региона, §3.4 Q6/Q7).
- [ ] `unmappable dadata region falls through to rossvyaz` (qc=0 но region не в справочнике).
- [ ] `all three layers fail returns unknown with null subject_code`.
После каждого — Step «commit» `feat(region): LeadRegionResolver — <case>` (или батч-коммит на 3-4 связанных кейса).
**Session 4 завершение:** `cd app && ./vendor/bin/pest tests/Unit/Services/LeadRegionResolverTest.php` все GREEN. Push. **Это самая важная сессия — не торопиться, ревью каждого кейса.**
---
## SESSION 5 — LeadRouter каскад + подмена региона
**Deliverable:** `LeadRouter::matchEligibleProjects` принимает `?int $resolvedSubjectCode`, фильтрует в 3 фазы (точное→РФ→запасной) для ОБОИХ путей (DIRECT + pivot), отдаёт ≤3 кандидата с атрибутом `routing_step`.
**Preconditions:** Sessions 1-4. **Решён вопрос D1** (random→deterministic подтверждён заказчиком).
**Files:**
- Modify: `app/app/Services/LeadRouter.php` (новый параметр + queryCandidates 3-фазы)
- Modify: `app/tests/Pest.php` (расширить `createRoutingSnapshotFromProject` параметром `string $regions = '{}'`)
- Test: `app/tests/Feature/Services/LeadRouterCascadeTest.php`
### Task 5.1 — Расширить тест-хелпер
- [ ] **Step 1:** В `createRoutingSnapshotFromProject` (Pest.php строки 128-150) добавить параметр `string $regions = '{}'` и подставить в insert вместо хардкода `'{}'` (строка 141). Существующие вызовы не ломаются (дефолт сохранён).
- [ ] **Step 2:** Прогнать существующий `LeadRouterTest.php` — GREEN (регресс не сломан).
- [ ] **Step 3: Коммит** `test(region): createRoutingSnapshotFromProject accepts regions param`.
### Task 5.2 — Каскад: сигнатура + 3 фазы (TDD)
> **Подход:** обернуть существующий SQL приватным `queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds, int $limit): Collection`. Он содержит развилку DIRECT vs pivot (как сейчас) + добавляет WHERE-фрагмент по фильтру. `matchEligibleProjects(SupplierProject $sp, ?int $resolvedSubjectCode = null)` оркестрирует 3 фазы (§3.9 псевдокод), проставляет `routing_step` на каждый Project через `$project->setAttribute('routing_step', N)`.
WHERE-фрагменты:
- `exact`: `AND ?::int = ANY(snap.regions)` (bind `$code`)
- `all_ru`: `AND snap.regions = '{}'::int[]`
- `any`: без региона-фильтра (текущее поведение)
- [ ] **Step 1: Падающие тесты** `LeadRouterCascadeTest.php` (Pest, `DatabaseTransactions` + `SharesSupplierPdo`, tenant-context '0'):
```php
it('step 1: exact region match wins', function (): void {
$sp = SupplierProject::query()->create(['platform'=>'B1','signal_type'=>'site','unique_key'=>'ex.ru','subject_code'=>82,'current_limit'=>0,'sync_status'=>'ok']);
// tenant A — регион 83 (СПб); tenant B — регион 82 (Москва)
$a = makeLinkedProject($sp, regions: '{83}'); // helper inline
$b = makeLinkedProject($sp, regions: '{82}');
$matched = app(LeadRouter::class)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$b->id]) // только Москва-проект
->and($matched->first()->routing_step)->toBe(1);
});
it('step 2: falls to all-RF when no exact match', function (): void {
// кандидат только с regions='{}' → routing_step=2 для resolvedSubjectCode=82
});
it('step 3: fallback channel when nobody subscribed to region', function (): void {
// кандидат с regions='{83}' только; resolvedSubjectCode=82 → никто не подписан, нет РФ →
// возвращается с routing_step=3 (подмена в Job, не здесь)
});
it('exact + all-RF combine up to cap=3', function (): void { /* 2 точных + 2 РФ → 3 взяты, точные первыми */ });
it('null resolvedSubjectCode skips exact, uses all-RF then fallback', function (): void { /* резолвер не сработал */ });
it('cascade works for DIRECT supplier_project path too', function (): void { /* platform=DIRECT */ });
```
(`makeLinkedProject($sp, regions)` — inline-хелпер в файле теста: создаёт tenant с балансом, project, `linkProjectToSupplier`, `createRoutingSnapshotFromProject($p, regions: $regions)`.)
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** каскада. Сохранить fail-loud `logIfNoSnapshot` (вызывать на финальном результате). `excludeTenantIds` для шага 2 = tenant_id из шага 1.
- [ ] **Step 4: PASS** + регресс `LeadRouterTest.php` GREEN (старые вызовы без 2-го параметра используют дефолт `null` → ведут себя как «any», но теперь через каскад → проверить что 0-региональные тесты не сломались; при необходимости старые snapshot'ы имеют `regions='{}'` → попадают в шаг 2 all_ru).
> **⚠️ Регрессионный риск:** существующие `LeadRouterTest` создают snapshot с `regions='{}'` и вызывают `matchEligibleProjects($sp)` без 2-го арг. С каскадом `resolvedSubjectCode=null` → шаг 1 пропускается → шаг 2 all_ru матчит `regions='{}'` → те же результаты. **Проверить это явно**; если расходится — поправить дефолтную ветку, чтобы `null` + любой regions вёл себя как старое «any» (backward-compat). Это решение зафиксировать в коммит-сообщении.
- [ ] **Step 5: Коммит** `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) with routing_step`.
**Session 5 завершение:** `cd app && ./vendor/bin/pest tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php` GREEN. Push.
---
## SESSION 6 — Интеграция в Job + CSV-merge + flag + раскатка
**Deliverable:** `RouteSupplierLeadJob` использует `LeadRegionResolver`, персистит резолв, передаёт `routing_step`, подменяет регион на шаге 3; CSV-merge обновляет по рангу источника; feature-flag; метрики; staging-smoke.
**Preconditions:** Sessions 1-5 все зелёные и смержены.
**Files:**
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` (handle + createDealCopyForProject + CSV-merge)
- Create: `app/app/Console/Commands/PhoneRegionSmokeCommand.php` (staging-smoke §9.4)
- Test: `app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php`
### Task 6.1 — Резолв до транзакции + persist (TDD)
> **Точка вставки** ([RouteSupplierLeadJob.php:151-160](../../../app/app/Jobs/RouteSupplierLeadJob.php#L151)). Сейчас: `$matched = $router->matchEligibleProjects($supplier); $selected = $distributor->selectRecipients($matched); $subjectCode = $tagResolver->resolve(...)`. Становится: резолв региона ДО `matchEligibleProjects`, persist в одной короткой `DB::transaction()`, затем `matchEligibleProjects($supplier, $resolution->subjectCode)`.
- [ ] **Step 1: Падающий тест** `RouteSupplierLeadJobRegionResolutionTest.php`:
```php
it('lead with phone uses dadata region not tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc'=>0,'type'=>'Мобильный','provider'=>'МТС','region'=>'Москва']], 200)]);
// lead с raw_payload tag='Санкт-Петербург' но phone резолвится в Москву(82)
// → deal.subject_code = 82, supplier_leads.resolved_subject_code=82, region_source='dadata'
// → строка в lead_region_resolution_log
});
it('region resolution logged per lead with cache_hit flag', function (): void { /* 1 строка в log */ });
it('lead with invalid phone falls back to tag', function (): void { /* phone='123' → region_source='tag' */ });
it('lead with resolver disabled via flag uses tag', function (): void { /* config dadata.enabled=false → tag-flow */ });
it('persistent idempotency: retry does not re-call dadata', function (): void { /* resolved_subject_code уже set → Http::assertNothingSent */ });
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация.** Инжектить `LeadRegionResolver $regionResolver` в `handle()`. После `$lead->update(['supplier_project_id'...])`:
```php
$resolution = $regionResolver->resolve($lead);
// persist в одной короткой транзакции (ДО циклов по проектам — HTTP не висит в tenant-tx)
DB::transaction(function () use ($lead, $resolution): void {
$lead->update([
'resolved_subject_code' => $resolution->subjectCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'phone_operator' => $resolution->phoneOperator,
]);
$this->logRegionResolution($lead, $resolution); // INSERT lead_region_resolution_log
});
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
$selected = $distributor->selectRecipients($matched);
```
Удалить старый `$subjectCode = $tagResolver->resolve(...)`. `RegionTagResolver` остаётся injected (его использует `LeadRegionResolver` как fallback — DI цепочка). Приватный `logRegionResolution()` пишет в `lead_region_resolution_log` через `pgsql_supplier`, телефон маскируется (§7.1: `7XXX***YYYY`).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist`.
### Task 6.2 — Подмена subject_code на шаге 3 (TDD)
- [ ] **Step 1: Падающий тест**`routing_step=3` проект получает deal с `subject_code` = первый из `project->regions`, `region_substituted=true`; `lead_region_resolution_log.actual_subject_code` = настоящий резолв. `routing_step<3` → настоящий subjectCode, `region_substituted=false`.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** §3.10. `createDealCopyForProject` получает `RegionResolution $resolution` (вместо `?int $subjectCode`). Внутри:
```php
$dealSubjectCode = ($project->routing_step ?? 1) < 3
? $resolution->subjectCode
: $this->pickSubstituteRegion($project, $resolution->subjectCode);
$dealRegionSubstituted = ($project->routing_step ?? 1) === 3;
// Deal::create([... 'subject_code'=>$dealSubjectCode, 'phone_operator'=>$resolution->phoneOperator, 'region_substituted'=>$dealRegionSubstituted])
```
`pickSubstituteRegion(Project $p, ?int $resolved): ?int` — пустой `$p->regions``$resolved`; иначе `$p->regions[0]`. Дописать `lead_region_resolution_log` UPDATE с `routing_step`/`actual_subject_code`/`substituted_subject_code` (или включить в Task 6.1 лог — решить при сборке, лог пишется ПОСЛЕ маршрутизации когда routing_step известен; возможно перенести запись лога из 6.1 в конец handle()).
> **NB порядок записи лога:** `routing_step` известен только ПОСЛЕ `matchEligibleProjects`. Значит INSERT в `lead_region_resolution_log` логичнее делать ПОСЛЕ цикла (с агрегатом routing_step) ИЛИ писать базовую строку в 6.1 и UPDATE'ить routing-поля после. Выбрать: **одна строка на лид** пишется в конце `handle()` с финальными routing-полями (subject_code лида один, routing_step берётся от первого selected-проекта или max). Зафиксировать решение в коммите.
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): step-3 fallback subject_code substitution + region_substituted`.
### Task 6.3 — CSV-merge update по рангу источника (TDD)
- [ ] **Step 1: Падающий тест** — CSV-recovered deal `region_source='tag'`, subject_code=99; webhook даёт `dadata` subject=82 → merge обновляет subject_code/phone_operator/region_source (rank 4>2). Равный/худший ранг → НЕ обновляет.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** §3.12 в merge-блоке (строки 340-369). При наличии `$existingMergeable` и нового `$resolution`: сравнить `RegionResolution::SOURCE_RANK`, если новый выше — добавить `subject_code`/`phone_operator`/`region_source` в `DB::table('deals')->where('id')->where('received_at')->update([...])`. **Сохранить `received_at` в WHERE** (partition pruning + FK, как в существующем коде, строки 357-360).
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Коммит** `feat(region): CSV-merge updates subject_code/operator by source rank`.
### Task 6.4 — Staging-smoke команда + метрики
- [ ] **Step 1:** `PhoneRegionSmokeCommand` (`phone-region:smoke --phone=...`) §9.4 — дёргает живой DaData+Россвязь, печатает решение, НЕ пишет в БД. Тест: команда с `Http::fake` печатает структуру.
- [ ] **Step 2:** Метрики §8.1 — инкременты `phone_resolution.source.*` / `dadata.qc.*` / `cache.{hit,miss}` через существующий механизм метрик проекта (проверить как проект шлёт в Sentry/Prometheus — grep `metric`/`Sentry::` в `app/app/Services`). Если механизма нет — отложить в отдельную задачу, отметить в коммите.
- [ ] **Step 3: Коммит** `feat(region): staging smoke command + resolution metrics`.
### Task 6.5 — Регрессия + handoff раскатки
- [ ] **Step 1:** Полная регрессия затронутого слоя: `cd app && ./vendor/bin/pest tests/Unit/Services tests/Feature/Services tests/Feature/Jobs tests/Feature/Migrations`. GREEN.
- [ ] **Step 2:** `superpowers:requesting-code-review` на весь диапазон фичи.
- [ ] **Step 3:** Документ-handoff раскатки (§10): порядок прод-шагов (миграция → импорт реестра → деплой с `LEAD_REGION_RESOLVER_ENABLED=false` → 1% → 100%), включая `DADATA_API_KEY`/`DADATA_SECRET` в YC Lockbox. Файл: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`.
- [ ] **Step 4: Финальный коммит + PR.** `superpowers:finishing-a-development-branch`.
**Session 6 завершение:** вся фича зелёная, code-review пройден, runbook готов. Фактический первый импорт реестра Россвязи + раскатка — оператором по runbook, ВНЕ этого плана.
---
## Self-Review (выполнено автором плана)
**Spec coverage:** §3.3 резолвер→Session 4; §3.4/§3.4.1 qc+ambiguous→Session 4; §3.7 Россвязь→Session 2; §3.6 DaData→Session 3; §3.9 каскад→Session 5; §3.10 подмена→Session 6.2; §3.11 persist/idempotency→Session 6.1; §3.12 CSV-merge→Session 6.3; §3.13 rate-limit→Session 3.4; §4 схема→Session 1; §5 config→Session 3.1; §6 импорт→Session 2.2; §8 метрики→Session 6.4; §9 тесты→распределены; §11 бюджет→config+guard Session 3. **Gap:** §7 (152-ФЗ маскирование) — покрыто частично (phone_masked в логе, Session 6.1); pg_anonymizer-маски (§7.2) НЕ выделены в задачу → **добавить в Session 1 Task 1.3 как комментарий схемы ИЛИ отдельную задачу раскатки** (low-risk, отметить для заказчика).
**Type consistency:** `RegionResolution` поля (`subjectCode`/`source`/`phoneOperator`/`qc`/`actualSubjectCode`) согласованы между Session 4 (определение), Session 5 (роутер не зависит от DTO), Session 6 (потребитель). `routing_step` — атрибут на `Project` (Session 5 пишет, Session 6 читает). `SOURCE_RANK` — один источник в `RegionResolution` (Session 4), потребляется в Session 6.3.
**Placeholders:** DDL, сигнатуры, имена тестов, точка интеграции — конкретны. Полные TDD-шаги для рутинных тестов внутри Session 4/6 описаны именами кейсов + поведением; при subagent-driven-development каждый кейс разворачивается исполнителем в write→fail→implement→pass (имена и ожидаемое поведение заданы точно).
---
## Порядок выполнения и ветки
1. Все 6 сессий — на одной ветке `feat/lead-region-resolution`, последовательно.
2. Каждая сессия = отдельный subagent-driven-development прогон с ревью между задачами (Pravila §15.1 — субагенты git только Sonnet/Opus, верификация commit-базы после каждого).
3. Между сессиями — пауза/чекпойнт заказчику (можно разнести по календарным дням).
4. Изоляция от параллельных сессий: если router-gate v4 streams ещё активны — работать в worktree (`superpowers:using-git-worktrees`), мерж в main отдельным чекпойнтом.
File diff suppressed because it is too large Load Diff
+7
View File
@@ -88,6 +88,13 @@ const SAFE_EXACT = [
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
/^composer\s+(?:show|outdated)\b/,
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
// `migrate(?=\s|$)` lookahead prevents `migrate:install` / `migrate:<unknown>` from matching bare `migrate`.
/^php\s+artisan\s+(?:test|migrate:fresh|migrate:rollback|migrate:refresh|migrate:reset|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/,
/^composer\s+(?:test|pint|stan|insights|rector)\b/,
/^(?:\.\/)?vendor\/bin\/pest\b/,
/^pest\b/,
];
export function classifyWhitelist(segments) {
+66
View File
@@ -161,3 +161,69 @@ describe('stderr redirect — 2>&1 fd-duplication (review fix)', () => {
expect(classifyBashCommand('cat a 2>&1 > out.txt', {}).result).toBe('block');
});
});
describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)', () => {
// Allowed: PHP/Laravel dev commands that were missing from whitelist
it.each([
'php artisan test',
'php artisan test --filter=Auth',
'php artisan migrate',
'php artisan migrate:fresh',
'php artisan migrate:rollback',
'php artisan migrate:refresh',
'php artisan migrate:reset',
'php artisan db:seed',
'php artisan cache:clear',
'php artisan config:clear',
'php artisan view:clear',
'php artisan route:clear',
'php artisan optimize:clear',
'composer test',
'composer pint',
'composer stan',
'composer insights',
'composer rector',
'pest',
'pest --filter=Foo',
'vendor/bin/pest',
'./vendor/bin/pest',
])('allows %s', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
});
// Critical: REPL and composer mutations remain hard-blocked
it.each([
['php artisan tinker', 'REPL = arbitrary PHP exec risk'],
['php artisan tinker --execute="exit"', 'tinker variant'],
['composer install', 'hard-blacklist'],
['composer require foo/bar', 'hard-blacklist'],
['composer update', 'hard-blacklist'],
['composer remove foo/bar', 'hard-blacklist'],
['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'],
])('still blocks %s (%s)', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('block');
});
// Critical: existing pre-existing v3.8 keep behaviour
it('keeps php artisan list/route:list/migrate:status allowed (pre-existing v3.8)', () => {
expect(classifyBashCommand('php artisan list', {}).result).toBe('allow');
expect(classifyBashCommand('php artisan route:list', {}).result).toBe('allow');
expect(classifyBashCommand('php artisan migrate:status', {}).result).toBe('allow');
});
// Critical: pest does NOT match pestilence-like prefixes (word boundary)
it('does not allow command names sharing prefix with pest', () => {
expect(classifyBashCommand('pestilence', {}).result).toBe('block');
});
// Critical: chain semantics still enforced — pest && rm x → block (rm is mutating)
it('still blocks chain with mutating part even if first part is whitelisted pest', () => {
expect(classifyBashCommand('pest && rm x', {}).result).toBe('block');
});
// Critical: composer-show/outdated still allowed (pre-existing v3.8)
it('keeps composer show/outdated allowed (pre-existing v3.8)', () => {
expect(classifyBashCommand('composer show', {}).result).toBe('allow');
expect(classifyBashCommand('composer outdated', {}).result).toBe('allow');
});
});