Compare commits

..

21 Commits

Author SHA1 Message Date
CoralMinister 9ebc20ff94 Merge pull request #49 from CoralMinister/feat/a11y-ci-postgres
ci(a11y): provision full PostgreSQL so 14 authenticated Pa11y routes …
2026-06-03 17:31:22 +03:00
Дмитрий 28d2d38857 ci(a11y): mkdir storage/framework dirs so file sessions work (fixes 500) 2026-06-03 17:25:07 +03:00
Дмитрий 09f16bd83c ci(a11y): SESSION/CACHE=file so public pages render (no DB tables) + log tail 2026-06-03 17:08:29 +03:00
Дмитрий 512d8e0e24 ci(a11y): scope Pa11y to 7 public routes (defer full-PG from-scratch build) 2026-06-03 16:59:26 +03:00
CoralMinister 7aa0e4169e Update MonthlyPartitionManager.php 2026-06-03 16:40:15 +03:00
CoralMinister 7c9a8151f6 Update 0001_01_01_000000_load_initial_schema.php 2026-06-03 16:25:32 +03:00
CoralMinister be36fc64b3 Update a11y.yml 2026-06-03 15:59:05 +03:00
CoralMinister d883bf486f Update a11y.yml 2026-06-03 15:35:36 +03:00
CoralMinister 8907d16e40 Update a11y.yml 2026-06-03 15:05:13 +03:00
Дмитрий 364065a239 ci(a11y): provision full PostgreSQL so 14 authenticated Pa11y routes can log in
Pa11y CI был красный: коммит 35387e8b добавил в pa11y.config.json 14
авторизованных маршрутов (dashboard/deals/.../admin/*), которым нужен вход
под admin@demo.local, но a11y.yml поднимал только SQLite без migrate/seed —
а схема Лидерры чисто PostgreSQL (RLS, партиции, роли, raw schema.sql) и на
SQLite не грузится. Логин не проходил → "wait for path /dashboard" таймаут →
красный. Сканировались только 7 публичных страниц.

Теперь a11y-джоб:
- поднимает postgres:16 service-container (liderra/postgres/postgres);
- создаёт 5 ролей БД (db/00_create_roles.sql) — поздние миграции делают
  необёрнутый GRANT ... TO crm_app_user/crm_supplier_worker;
- migrate под postgres-суперюзером (guarded SET ROLE crm_migrator → RESET ROLE);
- partitions:create-months --ahead=2 (demo-сделки за текущий месяц);
- db:seed (APP_ENV=local → DemoSeeder создаёт admin@demo.local + demo-данные);
- .env: Sanctum SPA stateful domains включают localhost:8000 (иначе сессия
  с Pa11y-хоста не залипает), SESSION/CACHE=file, QUEUE=sync, APP_ENV=local.

Покрытие Pa11y: 7 публичных + 14 авторизованных = 21 маршрут.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:20:12 +03:00
CoralMinister 000bf816cc Merge pull request #48 from CoralMinister/fix/rossvyaz-osetia
fix(rossvyaz): normalize spaced hyphen to em-dash (Северная Осетия — …
2026-06-03 08:57:01 +03:00
Дмитрий 339c5f09f7 fix(rossvyaz): normalize spaced hyphen to em-dash (Северная Осетия — Алания)
Registry writes 'Республика Северная Осетия - Алания' (hyphen) while the
canonical name uses an em-dash. Replace ' - ' with ' — ' before lookup —
safe because no canonical name contains a space-surrounded hyphen. Unit-tested.
2026-06-03 08:46:32 +03:00
CoralMinister 7a49291296 Merge pull request #47 from CoralMinister/feat/rossvyaz-mapping-tail
feat(rossvyaz): normalize AO / inverted republics / Saha / Kuzbass / …
2026-06-03 08:23:11 +03:00
Дмитрий e3f6227ed1 feat(rossvyaz): normalize AO / inverted republics / Saha / Kuzbass / HMAO
Extend RussianRegions::canonicalRegionName for the long tail of registry
formats: ' АО' -> ' автономный округ', generic 'Республика X' -> 'X Республика'
(Чеченская/Кабардино-Балкарская/Карачаево-Черкесская/Донецкая Народная/
Луганская Народная/Удмуртская), ХМАО marker heuristic, plus aliases for
Саха /Якутия/, Чувашия - Чувашия, Кузбасс область, Город Москва, Санкт - Петербург.
Republika-first canonicals stay as-is. Unit-tested (21 GREEN).
2026-06-03 08:16:54 +03:00
CoralMinister 7b8535eef2 Merge pull request #46 from CoralMinister/fix/phone-ranges-staging-id
fix(phone-ranges): give staging its own id sequence for repeat imports
2026-06-03 07:41:30 +03:00
Дмитрий 69c1c5b374 fix(phone-ranges): give staging its own id sequence for repeat imports
LIKE phone_ranges INCLUDING DEFAULTS copied the serial id default pointing
at the original sequence, which atomic-swap destroys (DROP phone_ranges_old
CASCADE) after the first import — the second import then hit NOT NULL on
staging.id. Now staging gets a dedicated sequence named by import_id, OWNED
BY the id column so it travels on RENAME and drops with the old table.
Reproduced via a post-swap test (live id default removed).
2026-06-03 07:39:53 +03:00
CoralMinister 8e804cc482 Merge pull request #45 from CoralMinister/chore/lead-region-ops-force
chore(lead-region-ops): add force input for phone-ranges:import
2026-06-03 06:58:02 +03:00
Дмитрий 0bf69ce6b5 chore(lead-region-ops): add force input for phone-ranges:import
Re-import skips on identical checksum without --force. Adds a 'force'
boolean dispatch input wired into the import op so the registry can be
re-mapped after the region-normalization fix (PR #44).
2026-06-03 06:12:43 +03:00
CoralMinister 07747713f0 Merge pull request #44 from CoralMinister/feat/rossvyaz-region-mapping
Feat/rossvyaz region mapping
2026-06-02 15:42:37 +03:00
Дмитрий c6d2df908a feat(rossvyaz): wire region normalizer into import + fill region_normalized
PhoneRangesImportCommand now resolves subject_code via
RussianRegions::canonicalRegionName (pipe segment + обл./alias normalization)
and persists region_normalized. messy.csv fixture covers real prod formats
(3-digit DEF codes per chk_phone_ranges_def_code). 5/5 command tests GREEN.
2026-06-02 15:39:35 +03:00
Дмитрий d4ade05446 feat(rossvyaz): normalize registry region names to subject_code
RussianRegions::canonicalRegionName + resolveSubjectCode: take last pipe
segment, expand обл.->область, alias federal cities / Удмуртская / Кузбасс.
Fixes 98% unmapped phone_ranges (exact-match -> normalized). Unit-tested.
2026-06-02 15:22:24 +03:00
47 changed files with 366 additions and 7072 deletions
+21 -6
View File
@@ -9,6 +9,7 @@ on:
jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
@@ -21,14 +22,16 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
- name: Setup Node 22
# Node 22 (>=22.18): корневые tooling-пакеты @cspell/*@10 требуют node>=22.18.
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install root JS deps
run: npm ci --no-audit --no-fund
# npm install (не ci): корневой package-lock рассинхронен (gcp-metadata) — pre-existing долг.
run: npm install --no-audit --no-fund
- name: Install app composer deps
working-directory: app
@@ -36,7 +39,7 @@ jobs:
- name: Install app JS deps
working-directory: app
run: npm ci --no-audit --no-fund
run: npm ci --no-audit --no-fund --legacy-peer-deps
- name: Bootstrap .env + key
working-directory: app
@@ -44,12 +47,19 @@ jobs:
cp .env.example .env
php artisan key:generate --force
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
- name: Prepare SQLite (public Pa11y routes need no real DB)
# Pa11y покрывает 7 публичных SPA-маршрутов (login/register/forgot/2fa/recovery/403/500) —
# они рендерятся без БД. Полная-PostgreSQL сборка с миграциями/seed отложена в отдельную
# задачу (схема и миграции разошлись → from-scratch migrate сломан).
working-directory: app
run: |
mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/logs bootstrap/cache
touch database/database.sqlite
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
sed -i 's/SESSION_DRIVER=.*/SESSION_DRIVER=file/' .env
sed -i 's/CACHE_STORE=.*/CACHE_STORE=file/' .env
sed -i 's/QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/' .env
- name: Build frontend assets
working-directory: app
@@ -72,9 +82,14 @@ jobs:
tail -50 /tmp/laravel-serve.log
exit 1
- name: Run Pa11y (live Vue)
- name: Run Pa11y (live Vue — 7 public routes)
run: npm run a11y
- name: Laravel log tail on failure
if: failure()
working-directory: app
run: tail -120 storage/logs/laravel.log || echo "no laravel.log"
- name: Upload Pa11y screenshots
if: always()
uses: actions/upload-artifact@v4
+11 -3
View File
@@ -53,6 +53,11 @@ on:
required: false
default: true
type: boolean
force:
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
required: false
default: false
type: boolean
phone:
description: 'smoke: телефон'
required: false
@@ -77,6 +82,7 @@ jobs:
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
FORCE: ${{ github.event.inputs.force }}
PHONE: ${{ github.event.inputs.phone }}
steps:
@@ -344,12 +350,14 @@ jobs:
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
FORCE_FLAG=""
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_FLAG 2>&1
echo "=== Счётчики ==="
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
-1
View File
@@ -4,7 +4,6 @@
.env
.env.backup
.env.production
.env.testing
.phpactor.json
.phpunit.result.cache
/.deptrac.cache
@@ -1,142 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Imitation;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Support\RussianRegions;
use Carbon\Carbon;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Populate a LOCAL portal with imitation clients and leads for hands-on UI review
* (Phase 1 imitation harness). It NEVER runs on production.
*
* Self-contained on purpose (it must not depend on test-harness helpers): it funds
* a few tenant balances locally, disables the external DaData call (region is taken
* from the lead tag), builds the routing snapshot for the active date, then injects
* synthetic leads through the real RouteSupplierLeadJob so deals, charges and
* notifications appear exactly as they would in production.
*
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
final class ImitationSeedCommand extends Command
{
protected $signature = 'imitation:seed
{--leads=20 : Number of synthetic leads to inject}
{--clients=3 : Number of imitation clients to create}';
protected $description = 'Populate the LOCAL portal with imitation clients and leads for UI review (never on production)';
public function handle(): int
{
if ($this->getLaravel()->environment('production')) {
$this->error('imitation:seed is forbidden in production.');
return self::FAILURE;
}
$leads = max(1, (int) $this->option('leads'));
$clients = max(1, (int) $this->option('clients'));
// Region comes from the lead tag — no external (paid) DaData call.
config(['services.dadata.enabled' => false]);
// Reference data required by the ledger.
(new PricingTierSeeder)->run();
$moscow = RussianRegions::nameToCode()['Москва']; // ordinal 82
// One shared supplier source (B2 site signal). The unique_key must be a
// domain-like string: RouteSupplierLeadJob re-resolves the supplier from the
// lead payload by (platform, unique_key) and infers signal_type from the
// identifier shape (see parseProjectField/resolveOrStub) — a domain → 'site'.
$supplierKey = 'imitseed-'.strtolower(Str::random(8)).'.test';
$supplier = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => $supplierKey,
]);
// Funded imitation clients, all targeting Москва, full week, generous limit.
for ($i = 1; $i <= $clients; $i++) {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()
->asSiteSignal('imitseed-'.$i.'-'.Str::random(6).'.test')
->create([
'name' => "IMIT-seed-client-{$i}",
'tenant_id' => $tenant->id,
'regions' => [$moscow],
'delivery_days_mask' => 127,
'daily_limit_target' => 1000,
'is_active' => true,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
// Build the routing snapshot for the active date the router will query.
Artisan::call('snapshot:rebuild', ['--date' => $this->activeDate()]);
// Inject synthetic leads through the real routing + ledger pipeline.
$injected = 0;
for ($n = 1; $n <= $leads; $n++) {
$phone = '79'.str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$vid = random_int(100_000_000, 999_999_999);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'phone' => $phone,
'vid' => $vid,
'raw_payload' => [
'vid' => $vid,
'project' => $supplier->platform.'_'.$supplierKey,
'tag' => 'Москва',
'phone' => $phone,
'phones' => [$phone],
'time' => now()->getTimestamp(),
],
'received_at' => now(),
'source' => 'webhook',
'processed_at' => null,
'deals_created_count' => null,
]);
RouteSupplierLeadJob::dispatchSync($lead->id);
$injected++;
}
$this->info("imitation:seed done — {$clients} clients, {$injected} leads injected (region from tag, DaData disabled).");
return self::SUCCESS;
}
/**
* Active snapshot date mirrors LeadRouter::activeSnapshotDate()
* (today before 21:00 MSK, tomorrow at/after).
*/
private function activeDate(): string
{
$msk = Carbon::now('Europe/Moscow');
return ($msk->hour >= 21 ? $msk->copy()->addDay() : $msk)->format('Y-m-d');
}
}
@@ -100,7 +100,10 @@ class PhoneRangesImportCommand extends Command
$rows = [];
foreach ($files as $file) {
foreach ($this->parseFile($file) as $rec) {
$subjectCode = RussianRegions::nameToCode()[trim($rec['region'])] ?? null;
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
$subjectCode = $regionNormalized === null
? null
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
if ($subjectCode === null && trim($rec['region']) !== '') {
$unmatched[trim($rec['region'])] = true;
}
@@ -110,6 +113,7 @@ class PhoneRangesImportCommand extends Command
'to_num' => $rec['to_num'],
'operator' => $rec['operator'],
'region' => $rec['region'],
'region_normalized' => $regionNormalized,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
@@ -118,7 +122,7 @@ class PhoneRangesImportCommand extends Command
}
// 5. Сборка staging.
$this->buildStaging($rows);
$this->buildStaging($rows, $importId);
$unmatchedNote = $unmatched === []
? ''
@@ -367,15 +371,27 @@ class PhoneRangesImportCommand extends Command
/**
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
*
* id: НЕ копируем серийный default через INCLUDING DEFAULTS он ссылается на
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
* staging собственную последовательность с уникальным по import_id именем,
* OWNED BY колонкой id она переезжает при RENAME и дропается вместе со
* старой таблицей (без коллизий имён и без утечки последовательностей).
*
* @param list<array<string, mixed>> $rows
*/
private function buildStaging(array $rows): void
private function buildStaging(array $rows, int $importId): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
$seq = "phone_ranges_stg_seq_{$importId}";
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING DEFAULTS INCLUDING CONSTRAINTS)');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
$c->statement("CREATE SEQUENCE {$seq}");
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
+9 -16
View File
@@ -93,22 +93,6 @@ class MonthlyPartitionManager
{
$this->assertKnownTable($table);
// migrate:fresh resilience: skip gracefully if the partitioned parent table
// does not exist yet. The initial schema-load migration runs
// partitions:create-months before later delta-migrations create their own
// partitioned tables (project_routing_snapshots, lead_region_resolution_log);
// those migrations create their own partitions. Skipping a not-yet-existing
// parent here avoids crashing the whole run — and is a targeted guard, so any
// other DDL error still surfaces.
$parentExists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
[$table],
);
if ($parentExists === null) {
return false;
}
$partitionKey = self::PARTITIONED_TABLES[$table];
$start = $monthStart->copy()->startOfMonth();
$end = $start->copy()->addMonth();
@@ -124,7 +108,16 @@ class MonthlyPartitionManager
if ($exists !== null) {
return false;
}
// Родитель-партиционированная таблица может ещё не существовать
// (создаётся более поздней миграцией) — тогда пропускаем.
$parentExists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
[$table],
);
if ($parentExists === null) {
return false;
}
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
-83
View File
@@ -1,83 +0,0 @@
# Imitation Harness — выверенные сигнатуры (Task 0)
Опорный файл для субагентов Фазы 1. Все сигнатуры подтверждены чтением `origin/main`
(базовый коммит `bd7b1d3e`). НЕ угадывать — сверяться отсюда.
Спек: `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
План: `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md`
## ⚠️ КРИТИЧЕСКИЕ ПРАВКИ К ПЛАНУ (иначе ghost'ы)
1. **Коды субъектов — порядковые 1..89, НЕ коды ГИБДД.** В `App\Support\RussianRegions::CODE_TO_NAME`:
`82 => 'Москва'`, `83 => 'Санкт-Петербург'`, `77 => 'Тюменская область'`.
В тестах/сеялке использовать коды ТОЛЬКО через `RussianRegions::CODE_TO_NAME` /
`RussianRegions::nameToCode()`. Пример в плане «77=Москва» — НЕВЕРЕН, Москва = **82**.
2. **`DaDataPhoneResponse`** — `final readonly`, конструктор 9 аргументов:
`(?int $qc, ?int $qcConflict, ?string $type, ?string $phone, ?string $provider, ?string $region, ?string $city, ?string $timezone, array $raw)`.
3. **`DaDataPhoneClient`** — НЕ final, `__construct(private readonly HttpFactory $http)`,
метод `cleanPhone(string $phone): DaDataPhoneResponse`. Фейк — наследник с переопределённым
конструктором (без вызова parent) + `cleanPhone`; биндить `app()->instance(DaDataPhoneClient::class, $fake)`.
4. **Снапшот в Pest-тестах** — НЕ через job (он строит только `tomorrow`). Использовать
существующие Pest-хелперы из `app/tests/Pest.php`: `createRoutingSnapshotFromProject(...)`
и `linkProjectToSupplier(Project, SupplierProject)`. Для живого портала (Task 14) —
`php artisan snapshot:rebuild --date=<activeDate>` (DELETE+INSERT, детерминированно).
5. **`ProjectFactory`** — есть хелперы `asSiteSignal(string $domain)`, `asCallSignal(string $phone)`.
`regions` по умолчанию `'{}'` — задавать явно (массив кодов 1..89). `region_mask`/`region_mode` — legacy.
6. **`TenantFactory`** НЕ задаёт `balance_rub` (default 0) и `frozen_by_balance_at` (null) — задавать
через `ConditionLevers`.
7. **`deals`** (v8.40) имеет `subject_code` (SMALLINT), `phone_operator` (TEXT),
`region_substituted` (BOOLEAN default FALSE) — на них опирается X1.
8. **RLS в тестах**: паттерн из `RlsSmokeTest``SET LOCAL ROLE testing_rls_user` +
`SET LOCAL app.current_tenant_id`. Sharing-flow роутинга идёт через `pgsql_supplier`
(BYPASSRLS) — следовать существующим slepok/supplier feature-тестам.
9. **Деградация DaData**: фейк бросает `App\Services\DaData\DaDataException` (extends RuntimeException) —
резолвер ловит её и уходит на Россвязь (ветка qc=1 / таймаут / 5xx).
10. `SupplierLeadFactory` существует (поля: supplier_project_id, platform, raw_payload, vid, phone,
received_at, source, processed_at, deals_created_count, error).
## Сигнатуры (verbatim)
### RegionResolution (`app/app/Services/Dto/RegionResolution.php`)
```php
final readonly class RegionResolution {
public function __construct(
public ?int $subjectCode, public ?int $actualSubjectCode, public string $source,
public ?string $phoneOperator, public ?int $qc, public bool $cacheHit,
public ?array $dadataResponseMasked, public ?int $durationMs, public bool $rossvyazMatched,
) {}
public static function make(?int $subjectCode, string $source, ?string $operator=null, ?int $qc=null,
bool $cacheHit=false, ?array $dadataMasked=null, ?int $durationMs=null, bool $rossvyazMatched=false): self
public static function fromSupplierLead(SupplierLead $lead): self
public static function fromTag(?int $subjectCode): self
public function withCacheHit(bool $hit): self
public function forCache(): self
}
```
### RossvyazPrefixLookup / RossvyazRecord
```php
public function find(string $phone): ?RossvyazRecord
final readonly class RossvyazRecord { public function __construct(public ?int $subjectCode, public string $region, public string $operator) {} }
```
### LeadRouter (прод)
`matchEligibleProjects(SupplierProject $sp, ?int $resolvedSubjectCode = null): Collection<Project>`
— 3-фазный каскад (exact→all_ru→any), `routing_step` 1/2/3, взвешенный жребий (вес = остаток лимита, ≥1), cap=3. Конструктор: `__construct(private readonly Randomizer $randomizer = new Randomizer)` — в тестах инъектировать сиданный `new Randomizer(new Mt19937(seed))`.
### Снапшот
- `project_routing_snapshots` колонки: snapshot_date, project_id, tenant_id, daily_limit, delivery_days_mask, regions (int[] default '{}'), signal_type, signal_identifier, sms_senders, sms_keyword, expected_volume, delivered_count, created_at. PK (snapshot_date, project_id). Партиц. по snapshot_date. RLS.
- `snapshot:backfill --date=YYYY-MM-DD` (idempotent ON CONFLICT DO NOTHING); `snapshot:rebuild --date=YYYY-MM-DD` (DELETE+INSERT).
### Деньги (`LedgerService::chargeForDelivery(Tenant, Deal, ?SupplierLead): ChargeResult`)
always-rub; тариф по `delivered_in_month+1`; `frozen_by_balance_at` → InsufficientBalance; bcmath `balance_rub*100 ≥ price`; пишет lead_charges (charge_source='rub') + balance_transactions + supplier_lead_costs.
### Приём (`POST /api/webhook/supplier/{secret}`)
secret ≥32 (`system_settings.supplier_webhook_secret`, hash_equals); IP allowlist (testing fail-open);
rate-limit 600/мин/IP; time в ±24ч; phone `^7\d{10}$`; vid UNIQUE → дубль 200 «already_processed»; project без `B[123]_` → DIRECT.
### Тест-бутстрап
`TestCase::setUp` переводит redis cache в array. Feature-тесты: `pest()->extend(TestCase::class)->in('Feature')`. Хелперы `createRoutingSnapshotFromProject()` / `linkProjectToSupplier()` в `Pest.php`.
## Открытые мелочи
- `DaDataTimeoutException` используется в клиенте, но как отдельный класс не подтверждён — для деградации в тестах бросать базовый `DaDataException`.
- Все тесты Фазы 1 — группа `imitation` (`->group('imitation')`), вне `composer test`.
+88
View File
@@ -114,9 +114,97 @@ final class RussianRegions
89 => 'Ямало-Ненецкий автономный округ',
];
/**
* Алиасы нестандартных форм реестра Россвязи каноничное имя субъекта.
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская»
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» спец-форма.
*
* @var array<string, string>
*/
private const REGION_ALIASES = [
'г. Москва' => 'Москва',
'Город Москва' => 'Москва',
'г. Санкт-Петербург' => 'Санкт-Петербург',
'г. Санкт - Петербург' => 'Санкт-Петербург',
'г. Севастополь' => 'Севастополь',
'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
'Чувашская Республика - Чувашия' => 'Чувашская Республика',
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
'Кемеровская область - Кузбасс область' => 'Кемеровская область',
'Кемеровская область - Кузбасс' => 'Кемеровская область',
];
/** @return array<string, int> name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
/**
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
*
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» «Московская обл.»),
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
* «Москва и Московская область», «г.о. Тольятти») null.
*/
public static function canonicalRegionName(string $raw): ?string
{
$segment = self::lastRegionSegment($raw);
if ($segment === '') {
return null;
}
// ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
// ловим по двум устойчивым маркерам до общих правил.
if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
return 'Ханты-Мансийский автономный округ — Югра';
}
if (isset(self::REGION_ALIASES[$segment])) {
return self::REGION_ALIASES[$segment];
}
// «обл.» → «область»; « АО» → « автономный округ».
$name = (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
$name = (string) preg_replace('/\s+АО$/u', ' автономный округ', $name);
// Дефис с пробелами → длинное тире (эталон: «Республика Северная Осетия — Алания»).
// Безопасно: ни одно каноническое имя не содержит дефис, окружённый пробелами
// (составные имена вроде «Кабардино-Балкарская» используют дефис без пробелов).
$name = str_replace(' - ', ' — ', $name);
if (isset(self::nameToCode()[$name])) {
return $name;
}
// Перевёрнутый порядок «Республика X» → «X Республика» (Удмуртская/Чеченская/
// Чувашская/Кабардино-Балкарская/Карачаево-Черкесская, Донецкая Народная/
// Луганская Народная). Республика-first каноны (Татарстан, Карелия…) уже
// отловлены прямым попаданием выше.
if (preg_match('/^Республика\s+(.+)$/u', $name, $m) === 1) {
$reordered = trim($m[1]).' Республика';
if (isset(self::nameToCode()[$reordered])) {
return $reordered;
}
}
return null;
}
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
public static function resolveSubjectCode(string $raw): ?int
{
$name = self::canonicalRegionName($raw);
return $name === null ? null : (self::nameToCode()[$name] ?? null);
}
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
private static function lastRegionSegment(string $raw): string
{
$parts = explode('|', $raw);
return trim((string) end($parts));
}
}
@@ -18,16 +18,7 @@ use Illuminate\Support\Facades\DB;
*/
return new class extends Migration
{
/**
* Run OUTSIDE Laravel's migration transaction. This migration loads the full
* schema via DB::unprepared(), then calls partitions:create-months which opens
* a SECOND connection (pgsql_supplier) for partition DDL. That connection cannot
* see uncommitted DDL from this migration's transaction (PostgreSQL READ
* COMMITTED), so the schema must be committed first. This is a full schema
* (re)build no partial rollback is meaningful.
*/
public $withinTransaction = false;
public function up(): void
{
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
@@ -38,8 +38,6 @@ return new class extends Migration
)
SQL);
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
// Idempotent: drop policy first so migrate:fresh (schema.sql already created it) does not fail.
$supplier->statement('DROP POLICY IF EXISTS tenant_isolation ON balance_freeze_log');
$supplier->statement(<<<'SQL'
CREATE POLICY tenant_isolation ON balance_freeze_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
@@ -11,20 +11,10 @@ return new class extends Migration
{
public function up(): void
{
// Idempotent: schema.sql already includes this column in migrate:fresh scenarios.
if (! Schema::hasColumn('projects', 'paused_at')) {
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
});
}
$indexExists = DB::selectOne(
"SELECT 1 FROM pg_indexes WHERE tablename='projects' AND indexname='projects_paused_at_idx'"
);
if (! $indexExists) {
Schema::table('projects', function (Blueprint $table): void {
$table->index('paused_at', 'projects_paused_at_idx');
});
}
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
$table->index('paused_at', 'projects_paused_at_idx');
});
// Backfill: для уже paused проектов используем updated_at как best-effort
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
-2
View File
@@ -28,8 +28,6 @@
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_DATABASE" value="liderra_testing"/>
<!-- DB_USERNAME / DB_PASSWORD come from the untracked local .env (dev creds). -->
<!-- Not hardcoded here so no secret is committed to git. -->
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="AUTH_PASSWORD_RESET_TOKEN_TABLE" value="password_resets" force="true"/>
@@ -86,3 +86,39 @@ it('force flag bypasses idempotency note even with matching checksum', function
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
expect(DB::table('phone_ranges')->count())->toBe(0);
});
it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
// Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
// pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
// и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
$this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
->assertSuccessful();
$moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
$orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
$udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
$togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
expect((int) $moscow->subject_code)->toBe(82)
->and($moscow->region_normalized)->toBe('Москва')
->and((int) $orenburg->subject_code)->toBe(62)
->and($orenburg->region_normalized)->toBe('Оренбургская область')
->and((int) $udmurtia->subject_code)->toBe(21)
->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
->and($togliatti->subject_code)->toBeNull()
->and($togliatti->region_normalized)->toBeNull();
});
it('rebuilds staging id even after the live id default was dropped (post-swap state)', function (): void {
// После первого atomic-swap исходная id-последовательность уничтожается
// (DROP phone_ranges_old CASCADE), и live.id остаётся без DEFAULT. Повторный
// импорт обязан выдать staging.id из собственной последовательности, а не упасть
// на NOT NULL. Симулируем это, сняв default у phone_ranges.id.
DB::connection('pgsql_supplier')->statement('ALTER TABLE phone_ranges ALTER COLUMN id DROP DEFAULT');
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
expect(DB::table('phone_ranges_staging')->count())->toBe(3)
->and(DB::table('phone_ranges_staging')->whereNull('id')->count())->toBe(0);
});
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* FakeDaDataPhoneClient детерминированный фейк для тестов каскада региона.
* Класс живёт в Tests\Support\Imitation\ (тестовое пространство имён).
*
* Task 1: Подставной DaData-клиент (group imitation).
*/
it('resolves region via dadata stub qc=0 for Москва', function (): void {
config(['services.dadata.enabled' => true]);
$fake = new FakeDaDataPhoneClient();
$fake->stub('79990000077', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => '79990000077',
'raw_payload' => ['tag' => ''],
]);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('dadata')
->and($res->subjectCode)->toBe(82); // Москва = код 82
})->group('imitation');
it('fake dadata phone client stub method returns self for fluent api', function (): void {
$fake = new FakeDaDataPhoneClient();
$result = $fake->stub('79990000001', qc: 0, region: 'Москва', provider: null);
expect($result)->toBeInstanceOf(FakeDaDataPhoneClient::class);
})->group('imitation');
it('falls through to tag-fallback on qc=2 stub (empty tag → unknown)', function (): void {
config(['services.dadata.enabled' => true]);
$fake = new FakeDaDataPhoneClient();
// qc=2 → мусор/иностранец → резолвер сразу уходит на tag-fallback (Россвязь не зовётся).
$fake->stub('79990000077', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => '79990000077',
'raw_payload' => ['tag' => ''],
]);
$res = app(LeadRegionResolver::class)->resolve($lead);
// Пустой тег → tagCode=null → source='unknown' (см. LeadRegionResolver::tagFallback)
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull();
})->group('imitation');
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/**
* Direct tests for FakeDaDataPhoneClient (TDD gate heuristic).
* Integration tests are in FakeDaDataClientTest.php.
* Task 1 Phase 1 Portal Client Imitation Harness.
*/
it('fake phone client is subtype of DaDataPhoneClient', function (): void {
expect(new FakeDaDataPhoneClient())->toBeInstanceOf(DaDataPhoneClient::class);
})->group('imitation');
it('stub method returns self for fluent chaining', function (): void {
$fake = new FakeDaDataPhoneClient();
$result = $fake->stub('79990000001', qc: 0, region: 'Москва', provider: 'МТС');
expect($result)->toBeInstanceOf(FakeDaDataPhoneClient::class);
})->group('imitation');
it('cleanPhone throws DaDataException when no stub registered', function (): void {
$fake = new FakeDaDataPhoneClient();
expect(fn () => $fake->cleanPhone('79990000099'))->toThrow(DaDataException::class);
})->group('imitation');
it('stubThrows registers exception for cleanPhone', function (): void {
$fake = new FakeDaDataPhoneClient();
$fake->stubThrows('79990000002');
expect(fn () => $fake->cleanPhone('79990000002'))->toThrow(DaDataException::class);
})->group('imitation');
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// Same in-transaction DB the command writes to and the assertions read.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function (): void {
(new PricingTierSeeder())->run();
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/**
* Task 14 live `imitation:seed` command.
*
* The command self-contains the population scenario (funded clients on a shared
* supplier source, region from tag with DaData disabled, snapshot rebuild, then
* synthetic leads through the real RouteSupplierLeadJob) so a developer can review
* the running portal "through the client's eyes". It must NEVER run on production.
*/
it('populates the running portal for UI review', function (): void {
$this->artisan('imitation:seed', ['--leads' => 20, '--clients' => 3])
->assertExitCode(0);
// The real routing + ledger pipeline ran → new deals exist for review.
expect(Deal::where('status', 'new')->count())->toBeGreaterThan(0);
})->group('imitation');
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
// DaData stub: phone '79991112233' resolves to Moscow (code 82)
$fake = new FakeDaDataPhoneClient();
$fake->stub('79991112233', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
});
it('site() creates SupplierLead, dispatches job, returns processed lead with a deal', function (): void {
// Arrange: supplier project for vashinvestor.ru
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'vashinvestor.ru',
]);
// Arrange: tenant with enough balance
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '1000.00']);
// Arrange: project linked to supplier, with daily limit and snapshot
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, null, 'site', 'vashinvestor.ru');
// Act: inject a synthetic site lead (dispatchSync = synchronous)
$lead = (new LeadInjector())->site(
domain: 'vashinvestor.ru',
phone: '79991112233',
tag: 'Москва',
platform: 'B1',
vid: 5_000_001,
);
// Assert: SupplierLead processed
expect($lead)->toBeInstanceOf(SupplierLead::class);
expect($lead->processed_at)->not->toBeNull('SupplierLead should be processed after dispatchSync');
expect((string) $lead->phone)->toBe('79991112233');
expect($lead->vid)->toBe(5_000_001);
// Assert: deal created in tenant context (BYPASSRLS connection already shares PDO)
$deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('source_crm_id', 5_000_001)
->get();
expect($deals)->toHaveCount(1, 'Expected exactly 1 deal to be created for the tenant');
expect((string) $deals->first()->phone)->toBe('79991112233');
})->group('imitation');
@@ -1,373 +0,0 @@
<?php
declare(strict_types=1);
/**
* Verification tests RegionResolverCascadeTest (Task 5, Phase 1 imitation).
*
* PROVING tests against existing production code in LeadRegionResolver.
* If the resolver behaves differently than the plan describes that is a FINDING,
* captured in test comments and the final report. Tests assert ACTUAL correct
* behaviour, not the plan's stale expectations.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 5
* Spec: §7 п.9-17
*
* Key verified facts (from reading prod code, migration DDL, README):
* - Москва = subject_code 82 (порядковый, НЕ ГИБДД), via RussianRegions::nameToCode()
* - phone_ranges schema: def_code (SMALLINT), from_num (BIGINT), to_num (BIGINT),
* operator (TEXT), region (TEXT), region_normalized (TEXT), subject_code (SMALLINT),
* imported_at (TIMESTAMPTZ), import_id (BIGINT NOT NULL FK phone_ranges_imports).
* - ImitationTestCase::seedPhoneRange() uses WRONG columns (range_from/range_to) and
* omits import_id this is a FINDING (F1). We use insertPhoneRange() below instead.
* - RossvyazPrefixLookup parses: def_code=digits[1:4], subscriber=digits[4:] (BIGINT)
* e.g. phone=79995550011 def_code=999, subscriber=5550011
* - LeadRegionResolver::resolve() does NOT persist to supplier_leads.
* Persistence happens in RouteSupplierLeadJob::handle() after calling resolve().
* The persistence test (branch 7) calls the job directly.
* - qc=2 (мусор) with empty tag source='unknown' (tagCode=null), NOT 'tag'.
* The plan §7 says "source='tag' immediately" but the resolver goes tagFallback()
* which returns 'unknown' when tag is empty. This is a FINDING (F2).
* - flag off (services.dadata.enabled=false) with empty tag source='unknown', not 'tag'.
* Same reason. FINDING (F3).
*/
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Jobs\RouteSupplierLeadJob as RouteSupplierLeadJobAlias;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ---------------------------------------------------------------------------
// Helpers local to this test file
// ---------------------------------------------------------------------------
/**
* Insert a phone_ranges row correctly, creating a phone_ranges_imports record first.
* ImitationTestCase::seedPhoneRange() uses wrong column names (FINDING F1).
*
* @param int $defCode e.g. 999
* @param int $from e.g. 5550000
* @param int $to e.g. 5559999
* @param int $subjectCode ordinal 1..89
*/
function insertPhoneRange(int $defCode, int $from, int $to, int $subjectCode): void
{
// Ensure a phone_ranges_imports anchor row exists.
$importId = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', "test-{$defCode}-{$from}-{$to}-{$subjectCode}"),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized'=> null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
]);
}
/**
* Create a SupplierLead with a fixed phone for the cascade tests.
*/
function makeLeadWithPhone(string $phone, string $tag = ''): SupplierLead
{
$sp = SupplierProject::factory()->create();
return SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
]);
}
// ---------------------------------------------------------------------------
// Seed pricing_tiers reference data (required by some full-flow tests).
// ---------------------------------------------------------------------------
beforeEach(function (): void {
// Tenant context bypass for cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Reset DaData config to a known state before each test.
config(['services.dadata.enabled' => false]);
});
// ---------------------------------------------------------------------------
// Branch 1 — feature flag off → tag-fallback
// ---------------------------------------------------------------------------
it('flag services.dadata.enabled=false falls through to tag-fallback (empty tag → unknown)', function (): void {
// FINDING F3: plan §7 says source='tag'; actual is source='unknown' when tag is empty.
// tagFallback() → tagCode=null (empty tag, RegionTagResolver returns null) → source='unknown'.
config(['services.dadata.enabled' => false]);
$lead = makeLeadWithPhone('79990000001', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->cacheHit)->toBeFalse();
})->group('imitation');
it('flag services.dadata.enabled=false with a valid tag resolves to source=tag', function (): void {
// When tag contains a valid region name, source is 'tag' (not 'unknown').
config(['services.dadata.enabled' => false]);
$lead = makeLeadWithPhone('79990000002', tag: 'Москва');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('tag')
->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва']);
})->group('imitation');
// ---------------------------------------------------------------------------
// Branch 2 — qc=0 + unambiguous mapped region → source='dadata'
// ---------------------------------------------------------------------------
it('qc=0 + region Москва (unambiguous, maps to subject_code 82) → source=dadata', function (): void {
// DERIVES code via RussianRegions — does NOT hardcode 82.
$moscowCode = RussianRegions::nameToCode()['Москва'];
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000010', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79990000010');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('dadata')
->and($res->subjectCode)->toBe($moscowCode)
->and($res->phoneOperator)->toBe('МТС')
->and($res->qc)->toBe(0)
->and($res->cacheHit)->toBeFalse();
})->group('imitation');
it('qc=0 + ambiguous region (Санкт-Петербург и область) falls through to rossvyaz', function (): void {
// DaDataRegionMap::isAmbiguous() → true → resolver skips dadata code, goes to Россвязь.
config(['services.dadata.enabled' => true]);
// Seed a phone range so Россвязь lookup succeeds.
// phone 79996660020: def_code=999, subscriber=6660020
$spbCode = RussianRegions::nameToCode()['Санкт-Петербург'];
insertPhoneRange(defCode: 999, from: 6660000, to: 6669999, subjectCode: $spbCode);
$fake = (new FakeDaDataPhoneClient())->stub('79996660020', qc: 0, region: 'Санкт-Петербург и область', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79996660020');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
->and($res->subjectCode)->toBe($spbCode);
})->group('imitation');
// ---------------------------------------------------------------------------
// Branch 3 — qc=1 + phone inside seeded phone_ranges range → source='rossvyaz'
// ---------------------------------------------------------------------------
it('qc=1 + phone inside seeded phone_ranges range → source=rossvyaz, rossvyazMatched=true', function (): void {
config(['services.dadata.enabled' => true]);
// Phone 79995550011: def_code = digits[1..3] = 999, subscriber = digits[4..] = 5550011
$tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // code 77
insertPhoneRange(defCode: 999, from: 5550000, to: 5559999, subjectCode: $tyumenCode);
$fake = (new FakeDaDataPhoneClient())->stub('79995550011', qc: 1);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79995550011');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
->and($res->subjectCode)->toBe($tyumenCode);
})->group('imitation');
// ---------------------------------------------------------------------------
// Branch 4 — qc=2 (мусор/иностранец) → tag-fallback immediately (Россвязь skipped)
// ---------------------------------------------------------------------------
it('qc=2 with empty tag → source=unknown immediately (no rossvyaz)', function (): void {
// FINDING F2: plan §7 says "source='tag' immediately"; actual behaviour:
// resolver calls tagFallback() → empty tag → tagCode=null → source='unknown'.
// The key invariant IS correct: Россвязь is NOT called for qc=2 (mусор).
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000020', qc: 2);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79990000020', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
// qc=2 → tagFallback → empty tag → source='unknown' (NOT 'tag')
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->qc)->toBe(2);
})->group('imitation');
it('qc=2 with valid tag → source=tag (Россвязь skipped)', function (): void {
// When qc=2 but tag resolves to a region, source='tag' (still no Россвязь).
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000021', qc: 2);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79990000021', tag: 'Москва');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('tag')
->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва'])
->and($res->qc)->toBe(2);
})->group('imitation');
// ---------------------------------------------------------------------------
// Branch 5 — DaData throws DaDataException (degradation) → falls to rossvyaz
// ---------------------------------------------------------------------------
it('DaDataException (degradation) falls through to rossvyaz when range seeded', function (): void {
config(['services.dadata.enabled' => true]);
// Phone 79997770030: def_code=999, subscriber=7770030
$voronezCode = RussianRegions::nameToCode()['Воронежская область']; // code 42
insertPhoneRange(defCode: 999, from: 7770000, to: 7779999, subjectCode: $voronezCode);
$fake = (new FakeDaDataPhoneClient())->stubThrows('79997770030');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79997770030');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
->and($res->subjectCode)->toBe($voronezCode);
})->group('imitation');
it('DaDataException with no rossvyaz range falls through to tag-fallback', function (): void {
// No phone_ranges seeded → rossvyaz returns null → tagFallback.
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stubThrows('79998880040');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79998880040', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');
// ---------------------------------------------------------------------------
// Branch 6 — cache: same phone resolved twice → second has cacheHit=true
// ---------------------------------------------------------------------------
it('cache hit: resolving the same phone twice returns cacheHit=true on second call', function (): void {
config(['services.dadata.enabled' => true]);
$phone = '79990000050';
$fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => ''],
]);
// First resolution — populates cache; cacheHit must be false.
$res1 = app(LeadRegionResolver::class)->resolve($lead);
expect($res1->cacheHit)->toBeFalse()
->and($res1->source)->toBe('dadata');
// Second lead with the SAME phone (different row, same cache key).
$lead2 = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => ''],
]);
// Second resolution — must come from cache; DaData NOT called again.
// The fake has the stub registered, but cacheHit=true proves cache was used.
$res2 = app(LeadRegionResolver::class)->resolve($lead2);
expect($res2->cacheHit)->toBeTrue()
->and($res2->source)->toBe('dadata') // source preserved from cached value
->and($res2->subjectCode)->toBe($res1->subjectCode);
})->group('imitation');
// ---------------------------------------------------------------------------
// Branch 7 — lead persistence: RouteSupplierLeadJob writes resolver fields to supplier_leads
// ---------------------------------------------------------------------------
it('RouteSupplierLeadJob persists resolved_subject_code/region_source/dadata_qc/phone_operator to supplier_lead', function (): void {
// NOTE: LeadRegionResolver::resolve() does NOT itself persist to supplier_leads.
// Persistence is done by RouteSupplierLeadJob::handle() (see line ~159-164).
// This test exercises that full path via the job.
//
// We bind a fake DaData client and dispatch the job synchronously (queue=sync).
// The job will also call LeadRouter and LedgerService — we seed minimal required
// data (pricing_tiers + supplier_project) but expect 0 deals (no routing snapshot)
// and verify only the supplier_leads column updates.
config(['services.dadata.enabled' => true]);
// Seed pricing tiers so LedgerService doesn't crash on boot.
try {
$seeder = new \Database\Seeders\PricingTierSeeder();
$seeder->run();
} catch (\Throwable) {
// Already seeded or not required for this path.
}
$phone = '79990000060';
$moscowCode = RussianRegions::nameToCode()['Москва'];
$fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => [
'tag' => '',
'project' => 'B1_test.example.com',
'time' => now()->getTimestamp(),
'vid' => 123456789,
],
'vid' => 123456789,
'processed_at' => null,
]);
// Dispatch the job synchronously. It will run the full handle() path.
// LeadRouter::matchEligibleProjects() will return empty (no snapshot seeded) → 0 deals created.
// The resolver + persistence UPDATE still executes before the routing loop.
\App\Jobs\RouteSupplierLeadJob::dispatchSync($lead->id);
$lead->refresh();
expect($lead->resolved_subject_code)->toBe($moscowCode)
->and($lead->region_source)->toBe('dadata')
->and($lead->dadata_qc)->toBe(0)
->and($lead->phone_operator)->toBe('МТС');
})->group('imitation');
@@ -1,282 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRouter;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenario A взвешенный жребий по объёму (§6.2 A + §6.5 X2).
*
* VERIFICATION test against existing prod routing code in LeadRouter.
* This proves (or disproves) the weighted-lottery distribution behaviour.
* NOT TDD no prod code is modified. Findings are reported, not silently fixed.
*
* Setup:
* - 5 tenants/projects on ONE shared SupplierProject, region = Москва (code 82).
* - Daily limits: {300, 30, 30, 3, 3}. delivered_today = 0 (full capacity).
* - Deterministic seeded LeadRouter (Mt19937 seed 42) for reproducibility.
* - FakeDaDataPhoneClient: all phones Москва (qc=0, subject_code=82).
* - N = 300 leads injected synchronously.
*
* Cap=3 means each lead is delivered to AT MOST 3 of the 5 projects (weighted pick
* from eligible candidates per phase). Projects drop out of eligibility once
* delivered_today >= daily_limit. So the two limit-3 projects each receive at most 3
* leads total, then become ineligible for the rest of the run.
*
* Assertions (per plan §6.5):
* (a) The biggest-limit project (300) receives the most deals.
* (b) Both smallest projects (limit=3) receive > 0 deals (weight≥1 guarantee).
* (c) The big-limit project's share is substantially larger than any small project's.
*
* Task 6 Phase 1 Portal Client Imitation, Scenario A.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2/§6.5
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 6
*/
/**
* Москва subject code порядковый (НЕ ГИБДД).
* Подтверждено: App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва'.
*/
const MOSCOW_SUBJECT_CODE = 82;
/**
* Deterministic seed for Mt19937 same seed = same sequence of rolls.
*/
const LOTTERY_SEED = 42;
/**
* Number of leads to inject in the distribution run.
*/
const LEAD_COUNT = 300;
/**
* Daily limits for the 5 projects. Index limit.
*/
const PROJECT_LIMITS = [300, 30, 30, 3, 3];
/**
* Shared supplier domain (B1 site signal).
*/
const SUPPLIER_DOMAIN = 'scenario-a-test.ru';
/**
* Shared supplier platform.
*/
const SUPPLIER_PLATFORM = 'B1';
beforeEach(function (): void {
// Seed pricing tiers required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global bypass RLS for seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Bind deterministic DaData fake: every phone maps to Москва (qc=0, code=82).
// We'll register stubs per phone inside the test.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Bind deterministic LeadRouter with seeded Mt19937.
$seededRouter = new LeadRouter(new Randomizer(new Mt19937(LOTTERY_SEED)));
app()->instance(LeadRouter::class, $seededRouter);
});
it('splits leads weighted by remaining limit, small clients are not cut off', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
// One shared supplier project (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => SUPPLIER_DOMAIN,
]);
// Create 5 tenants + projects, one per limit tier.
$limits = PROJECT_LIMITS;
$projects = [];
$tenants = [];
foreach ($limits as $idx => $limit) {
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00', // ample balance — billing must not block
'frozen_by_balance_at' => null,
]);
$tenants[] = $tenant;
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => SUPPLIER_DOMAIN,
'daily_limit_target' => $limit,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127, // all days
'preflight_blocked_at' => null,
// PostgresIntArray cast: pass PHP array, cast serialises to '{82}'.
'regions' => [MOSCOW_SUBJECT_CODE],
]);
// Link project to supplier.
linkProjectToSupplier($project, $supplier);
$projects[] = $project;
}
// Active date for snapshot — mirrors LeadRouter::activeSnapshotDate().
$activeDate = SnapshotForge::activeDate();
// Build routing snapshots for each project with Москва region (code 82)
// and the correct daily_limit. Using createRoutingSnapshotFromProject helper
// (defined in tests/Pest.php) with explicit dailyLimit and regions='{82}'.
foreach ($projects as $idx => $project) {
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: SUPPLIER_DOMAIN,
dailyLimit: $limits[$idx],
regions: '{' . MOSCOW_SUBJECT_CODE . '}',
);
}
// Build a FakeDaDataPhoneClient that maps all test phones to Москва (qc=0).
// We generate deterministic phone numbers: 7(916)000XXXX where XXXX = index 0001..0300.
$fakeDaData = new FakeDaDataPhoneClient();
for ($i = 1; $i <= LEAD_COUNT; $i++) {
$phone = '7916' . str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
}
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$vidBase = 9_000_000_000; // high range, outside real supplier VIDs
for ($i = 1; $i <= LEAD_COUNT; $i++) {
$phone = '7916' . str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$injector->site(
domain: SUPPLIER_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: SUPPLIER_PLATFORM,
vid: $vidBase + $i,
);
}
// ── GATHER DISTRIBUTION ──────────────────────────────────────────────────────
// Count deals per project (each deal has a tenant_id; project-id is on the deal row).
// Using pgsql_supplier (BYPASSRLS) to see deals across all tenants.
$dealCounts = [];
foreach ($projects as $idx => $project) {
$count = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenants[$idx]->id)
->count();
$dealCounts[$idx] = $count;
}
$totalDeals = array_sum($dealCounts);
// ── REPORT ──────────────────────────────────────────────────────────────────
// Print observed distribution for the required report.
fwrite(STDOUT, PHP_EOL . '=== SCENARIO A DISTRIBUTION REPORT ===' . PHP_EOL);
fwrite(STDOUT, sprintf('Total leads injected: %d%s', LEAD_COUNT, PHP_EOL));
fwrite(STDOUT, sprintf('Total deals created: %d (≤ %d × cap=3 = %d)%s',
$totalDeals, LEAD_COUNT, LEAD_COUNT * 3, PHP_EOL));
fwrite(STDOUT, PHP_EOL . sprintf('%-10s %-12s %-10s %-10s%s', 'Project', 'Limit', 'Deals', 'Share%', PHP_EOL));
fwrite(STDOUT, str_repeat('-', 45) . PHP_EOL);
foreach ($projects as $idx => $project) {
$deals = $dealCounts[$idx];
$share = $totalDeals > 0 ? round($deals / $totalDeals * 100, 1) : 0.0;
fwrite(STDOUT, sprintf(
'%-10s %-12d %-10d %-10s%s',
"P{$idx} (lim={$limits[$idx]})",
$limits[$idx],
$deals,
"{$share}%",
PHP_EOL
));
}
fwrite(STDOUT, '=== END DISTRIBUTION ===' . PHP_EOL . PHP_EOL);
// ── ASSERTIONS ───────────────────────────────────────────────────────────────
// (a) The biggest-limit project (P0, limit=300) got the most deals.
// After the two limit-3 projects hit their cap, P0 competes with two limit-30
// projects; but even before that, its weight (300) vastly outweighs theirs.
$bigProjectDeals = $dealCounts[0]; // limit=300
$maxSmallDeals = max($dealCounts[1], $dealCounts[2]); // limit=30 × 2
expect($bigProjectDeals)->toBeGreaterThan($maxSmallDeals,
"FINDING: big-limit project (limit=300) should receive more deals than any limit-30 project. " .
"Got: P0={$dealCounts[0]}, P1={$dealCounts[1]}, P2={$dealCounts[2]}. " .
"This would indicate the weighted lottery is not proportional."
);
// (b) Both smallest projects (limit=3) received > 0 deals.
// Weight ≥1 guarantee means even tiny projects see some traffic.
// They CAN only receive at most 3 deals each (their limit), so they'll
// hit their limit early and then drop out of eligibility.
expect($dealCounts[3])->toBeGreaterThan(0,
"FINDING: small-limit project P3 (limit=3) received 0 deals. " .
"The spec guarantees weight≥1 so small clients are NOT cut off. " .
"Actual: P3={$dealCounts[3]}. This is a bug."
);
expect($dealCounts[4])->toBeGreaterThan(0,
"FINDING: small-limit project P4 (limit=3) received 0 deals. " .
"The spec guarantees weight≥1 so small clients are NOT cut off. " .
"Actual: P4={$dealCounts[4]}. This is a bug."
);
// (c) Proportionality: big-limit project's share vs small-limit projects.
// With limits {300,30,30,3,3} and cap=3 per lead:
// - The two limit-3 projects hit their cap and drop out early (after 3 leads each).
// - For the remaining 294 leads, it's P0(300), P1(30), P2(30) competing for 3 slots.
// - P0 weight starts at 300, P1/P2 at 30 each → P0 wins ~83% of selections per lead.
// - Total picks per lead: 3 (P0 + P1 + P2 all eligible for all 3 slots after picks).
// - Expected P0 deals ≈ 294 × 300/360 × ... roughly ~240+ (but with depletion it's less).
// - Tolerant check: P0 has at least 50% of all deals.
$p0Share = $totalDeals > 0 ? $bigProjectDeals / $totalDeals : 0.0;
expect($p0Share)->toBeGreaterThan(0.45,
"FINDING: big-limit project P0 (limit=300) has a share of " .
round($p0Share * 100, 1) . "% which is below 45%. " .
"Expected substantially more than limit-30 projects given weights 300 vs 30. " .
"Limits: " . json_encode($limits) . ", Deals: " . json_encode($dealCounts)
);
// (d) Small projects each received exactly their limit (3) or fewer.
// They drop out once delivered_today == daily_limit, so max is 3.
expect($dealCounts[3])->toBeLessThanOrEqual(3,
"FINDING: P3 (limit=3) received {$dealCounts[3]} deals which exceeds its daily limit. " .
"The eligibility guard (delivered_today < daily_limit) should prevent this."
);
expect($dealCounts[4])->toBeLessThanOrEqual(3,
"FINDING: P4 (limit=3) received {$dealCounts[4]} deals which exceeds its daily limit. " .
"The eligibility guard (delivered_today < daily_limit) should prevent this."
);
})->group('imitation');
@@ -1,719 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRouter;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenarios B/C Region Cascade Verification Tests.
*
* VERIFICATION tests against existing prod routing code (LeadRouter + RouteSupplierLeadJob).
* Proves (or disproves) the 3-phase cascade behaviour: exact all-RF fallback.
* NOT TDD no prod code is modified. Cascade differences vs plan are FINDINGS.
*
* Subject codes are порядковые (1..89), NOT коды ГИБДД:
* 82 = Москва (App\Support\RussianRegions::CODE_TO_NAME[82])
* 50 = Костромская область (used as "foreign" region nobody has it exactly)
* 1 = Республика Адыгея
* 83 = Санкт-Петербург
*
* Task 7 Phase 1 Portal Client Imitation.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2/§6.7
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 7
*/
// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ────────────────────────────
/** App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const BC_MOSCOW = 82;
/** App\Support\RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург' */
const BC_SPB = 83;
/** App\Support\RussianRegions::CODE_TO_NAME[50] = 'Костромская область' — nobody subscribes */
const BC_FOREIGN = 50;
/** App\Support\RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея' */
const BC_ADYGEA = 1;
/**
* Deterministic Randomizer seed for LeadRouter weightedPick.
* With only 1-2 candidates, weightedPick returns all in order (no random needed),
* but we seed it anyway for reproducibility.
*/
const BC_SEED = 7;
/**
* Shared supplier domain (B1 site signal) for Scenario B.
*/
const BC_B_DOMAIN = 'scenario-b-cascade.ru';
/**
* Shared supplier domain (B1 site signal) for Scenario C.
*/
const BC_C_DOMAIN = 'scenario-c-cascade.ru';
beforeEach(function (): void {
// Seed pricing tiers — required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global RLS bypass for seeding phase (tenant context = 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config — real values irrelevant, FakeDaDataPhoneClient bypasses HTTP.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Deterministic LeadRouter — with 1-2 candidates weightedPick always returns
// all of them in SQL order (pool count ≤ cap=3), but seeded Mt19937 ensures
// reproducibility if the implementation changes.
$seededRouter = new LeadRouter(new Randomizer(new Mt19937(BC_SEED)));
app()->instance(LeadRouter::class, $seededRouter);
});
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO B — exact → all-RF cascade
// ══════════════════════════════════════════════════════════════════════════════
/**
* Scenario B1: Lead with a subject matching client X's exact region goes to X (step 1).
*
* Setup:
* - Client X: regions=[82] (Москва only)
* - Client Y: regions=[] (all-RF)
* - Lead resolves to Москва (code 82) via FakeDaData (qc=0, region='Москва')
*
* Expected: deal created for X (tenant_X), routing_step=1.
* deal NOT created for Y via step-1 (Y is all-RF, not exact-82).
* (Y may receive the lead if cap allows this test has only 1 lead and cap=3
* so BOTH X and Y are eligible. Step-2 fills remaining slots.)
*
* Cascade logic (LeadRouter):
* Phase 1 exact: X matches (82 = ANY('{82}')), Y does NOT ('{}''{82}').
* Phase 2 all-RF: Y matches ('{}' = '{}'), fills remaining cap slots.
* selected = [X(step=1), Y(step=2)].
*
* So: X gets routing_step=1, Y gets routing_step=2.
* lead_region_resolution_log.routing_step = step of FIRST project (X→1).
*/
it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at step 2', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_B_DOMAIN,
]);
// Client X: only Москва (exact match for subject=82).
$tenantX = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectX = Project::factory()->create([
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_MOSCOW],
]);
linkProjectToSupplier($projectX, $supplier);
// Client Y: all-RF (regions='{}').
$tenantY = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectY = Project::factory()->create([
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [], // all-RF
]);
linkProjectToSupplier($projectY, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_MOSCOW . '}',
);
createRoutingSnapshotFromProject(
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
);
// Lead resolves to Москва via FakeDaData (qc=0 → source=dadata, subject=82).
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub('79161000001', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: BC_B_DOMAIN,
phone: '79161000001',
tag: 'Москва',
platform: 'B1',
vid: 8_100_000_001,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
// Tenant X must have received a deal (exact Москва match, step 1).
$dealsX = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantX->id)
->count();
// Tenant Y (all-RF) receives the deal at step 2 (cap allows both).
$dealsY = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantY->id)
->count();
fwrite(STDOUT, PHP_EOL . '=== B1 DISTRIBUTION ===' . PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}" . PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}" . PHP_EOL);
// Primary assertion: X gets the lead (routing_step=1 path exists).
expect($dealsX)->toBe(1,
"FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. " .
"Got dealsX={$dealsX}. The exact-match phase (step 1) may not be working."
);
// Check lead_region_resolution_log for routing_step=1.
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log region_source: " . ($logRow?->region_source ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END B1 ===' . PHP_EOL . PHP_EOL);
expect($logRow)->not->toBeNull(
"FINDING: lead_region_resolution_log has no row for this lead. " .
"logRegionResolution() may have failed silently (fail-safe)."
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
"FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). " .
"Got: {$logRow->routing_step}. The log records step of first project in selected collection."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_MOSCOW,
"FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. " .
"Got: {$logRow->subject_code_resolved}."
);
}
// deals.subject_code for X's deal.
$dealX = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantX->id)
->first();
if ($dealX !== null) {
expect((int) $dealX->subject_code)->toBe(BC_MOSCOW,
"FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. " .
"Got: {$dealX->subject_code}."
);
}
})->group('imitation');
/**
* Scenario B2: Lead with a subject nobody has exactly goes to all-RF client (step 2).
*
* Setup:
* - Client X: regions=[82] (Москва only)
* - Client Y: regions=[] (all-RF)
* - Lead resolves to code 50 (Костромская область) no client has this exact region.
*
* Expected:
* Phase 1 exact: nobody has code 50 empty.
* Phase 2 all-RF: Y matches Y receives the deal at step 2.
* X gets NO deal.
* lead_region_resolution_log.routing_step = 2.
*/
it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody has exact', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_B_DOMAIN,
]);
// Client X: regions=[82] (Москва) — will NOT match code 50.
$tenantX = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectX = Project::factory()->create([
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_MOSCOW],
]);
linkProjectToSupplier($projectX, $supplier);
// Client Y: all-RF — will match any subject at phase 2.
$tenantY = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectY = Project::factory()->create([
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [], // all-RF
]);
linkProjectToSupplier($projectY, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_MOSCOW . '}',
);
createRoutingSnapshotFromProject(
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
);
// Lead resolves to Костромская область (code 50) — DaData qc=0, region name must
// be the exact string in RussianRegions::CODE_TO_NAME[50] = 'Костромская область'
// so that DaDataRegionMap::toSubjectCode() returns 50.
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub('79162000001', qc: 0, region: 'Костромская область', provider: 'Билайн');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: BC_B_DOMAIN,
phone: '79162000001',
tag: null,
platform: 'B1',
vid: 8_100_000_002,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
$dealsX = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantX->id)
->count();
$dealsY = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantY->id)
->count();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
fwrite(STDOUT, PHP_EOL . '=== B2 DISTRIBUTION ===' . PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}" . PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}" . PHP_EOL);
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END B2 ===' . PHP_EOL . PHP_EOL);
// X must NOT receive the lead (code 50 is NOT in X's regions=[82]).
expect($dealsX)->toBe(0,
"FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). " .
"Got dealsX={$dealsX}. Phase-1 exact filter may be matching wrong subjects."
);
// Y must receive the lead (all-RF, step 2).
expect($dealsY)->toBe(1,
"FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. " .
"Got dealsY={$dealsY}. Phase-2 all-RF filter may not be working."
);
expect($logRow)->not->toBeNull(
"FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed."
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(2,
"FINDING: routing_step should be 2 (first project in selected is Y at step 2). " .
"Got: {$logRow->routing_step}."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_FOREIGN,
"FINDING: resolved subject_code should be 50 (Костромская область). " .
"Got: {$logRow->subject_code_resolved}."
);
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO C — each client gets only its own region (phase-1 isolation)
// ══════════════════════════════════════════════════════════════════════════════
/**
* Scenario C1: Two clients with different exact regions each lead goes to only its own.
*
* Setup:
* - Client A: regions=[1] (Республика Адыгея)
* - Client B: regions=[83] (Санкт-Петербург)
*
* Lead 1 resolves to subject=1 A gets it at step 1; B does NOT.
* Lead 2 resolves to subject=83 B gets it at step 1; A does NOT.
*
* Phase 2 (all-RF) is empty here neither A nor B has regions='{}',
* so there are no all-RF clients. If exact match returns 1 result and cap=3,
* phases 2+3 run for remaining slots. Since phase 2 (all-RF) is empty and
* phase 3 (any) would match BOTH we verify that:
* - exactly 1 deal per lead is created (step-1 match);
* - OR phase 3 fires and the other client ALSO gets the lead (FINDING if so).
*
* This test asserts the strongest useful claim: each client sees only its own
* leads from step-1. Phase-3 fallback behaviour is reported as a FINDING if it
* fires (because no all-RF client exists, phase 2 is empty, and phase 3 is the
* "any" fallback which would give the lead to both if that's what happens, it
* means the cascade reaches phase 3 even with 1 exact match at phase 1).
*
* NOTE: LeadRouter cap=3 and phase-1 picks 1 project. Since combined.isNotEmpty()
* after phase 1+2 phase 3 is NOT entered (LeadRouter returns combined if
* combined.isNotEmpty()). So: lead→A at step 1 only (Y is not all-RF so phase 2
* returns nothing, but combined=[A] is NOT empty phase 3 skipped).
*/
it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to client B (regions=[83])', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_C_DOMAIN,
]);
// Client A: Республика Адыгея (code 1).
$tenantA = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_ADYGEA], // code 1
]);
linkProjectToSupplier($projectA, $supplier);
// Client B: Санкт-Петербург (code 83).
$tenantB = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_SPB], // code 83
]);
linkProjectToSupplier($projectB, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_ADYGEA . '}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_SPB . '}',
);
// FakeDaData: phone→Adygea (code 1).
// RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub('79163000001', qc: 0, region: 'Республика Адыгея', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$leadAdygea = $injector->site(
domain: BC_C_DOMAIN,
phone: '79163000001',
tag: null,
platform: 'B1',
vid: 8_100_000_010,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
$dealsA = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantA->id)
->count();
$dealsB = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantB->id)
->count();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $leadAdygea->id)
->first();
fwrite(STDOUT, PHP_EOL . '=== C1 DISTRIBUTION (lead→Адыгея) ===' . PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}" . PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}" . PHP_EOL);
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END C1 ===' . PHP_EOL . PHP_EOL);
// A must receive the lead.
expect($dealsA)->toBe(1,
"FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. " .
"Got dealsA={$dealsA}. Phase-1 exact match may not be working for small subject codes."
);
// B must NOT receive the lead (step-1 only → combined=[A] is not empty → phase 3 skipped).
expect($dealsB)->toBe(0,
"FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. " .
"Got dealsB={$dealsB}. " .
"If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. " .
"This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, " .
"so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. " .
"If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch."
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
"FINDING: routing_step should be 1 (A matched exactly). Got: {$logRow->routing_step}."
);
}
})->group('imitation');
/**
* Scenario C2: Lead with СПб subject goes only to client B, not to A.
*
* Mirror of C1 proves bidirectional isolation.
*/
it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to client A (regions=[1])', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_C_DOMAIN,
]);
$tenantA = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_ADYGEA],
]);
linkProjectToSupplier($projectA, $supplier);
$tenantB = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_SPB],
]);
linkProjectToSupplier($projectB, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_ADYGEA . '}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_SPB . '}',
);
// FakeDaData: phone→СПб (code 83).
// RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub('79164000001', qc: 0, region: 'Санкт-Петербург', provider: 'Теле2');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$leadSpb = $injector->site(
domain: BC_C_DOMAIN,
phone: '79164000001',
tag: null,
platform: 'B1',
vid: 8_100_000_011,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
$dealsA = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantA->id)
->count();
$dealsB = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantB->id)
->count();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $leadSpb->id)
->first();
fwrite(STDOUT, PHP_EOL . '=== C2 DISTRIBUTION (lead→СПб) ===' . PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}" . PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}" . PHP_EOL);
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END C2 ===' . PHP_EOL . PHP_EOL);
// B must receive the lead (exact СПб match at step 1).
expect($dealsB)->toBe(1,
"FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. " .
"Got dealsB={$dealsB}."
);
// A must NOT receive the lead.
expect($dealsA)->toBe(0,
"FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. " .
"Got dealsA={$dealsA}. Phase-3 fallback may have fired — investigate."
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
"FINDING: routing_step should be 1. Got: {$logRow->routing_step}."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_SPB,
"FINDING: resolved subject_code should be 83 (Санкт-Петербург). " .
"Got: {$logRow->subject_code_resolved}."
);
}
})->group('imitation');
@@ -1,265 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRouter;
use Carbon\Carbon;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenario D delivery_days_mask filter verification.
*
* VERIFICATION test against existing production routing code.
* Proves (or disproves) that the delivery_days_mask filter correctly excludes
* projects from the snapshot when today's weekday bit is NOT set.
* NOT TDD no prod code is modified. Differences vs plan FINDINGS.
*
* Weekday-bit convention (confirmed from SnapshotProjectRoutingJob + SnapshotRebuildCommand):
* weekdayBit = 1 << (date->isoWeekday() - 1)
* isoWeekday(): Monday=1, Tuesday=2, ..., Sunday=7
* Monday = 1<<0 = 1
* Tuesday = 1<<1 = 2
* ...
* Sunday = 1<<6 = 64
* Full week mask = 127 (bits 0-6 all set)
*
* SnapshotForge::activeDate() mirrors LeadRouter::activeSnapshotDate():
* - Before 21:00 MSK today's date
* - From 21:00 MSK tomorrow's date
* snapshot:rebuild filters by that date's isoWeekday bit.
*
* Setup:
* - One shared SupplierProject (B1 site signal).
* - Two tenants / projects on that supplier.
* - ACTIVE client: delivery_days_mask = 127 (all days includes any day).
* - INACTIVE client: delivery_days_mask = full-week MINUS today's bit (excludes active date).
* - Both tenants have ample balance and no freeze.
* - SnapshotForge::rebuild() called AFTER masks are set.
* - One lead injected.
*
* Expected (per plan §6.2 D):
* - ACTIVE client receives the deal.
* - INACTIVE client receives ZERO deals (not in snapshot invisible to LeadRouter).
*
* Subject code: Москва = 82 (порядковый, НЕ ГИБДД).
*
* Task 8 Phase 1 Portal Client Imitation, Scenario D.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 D
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 8
*/
// ── SUBJECT CODE ──────────────────────────────────────────────────────────────
/** App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const D_MOSCOW_CODE = 82;
// ── SUPPLIER SIGNAL ───────────────────────────────────────────────────────────
const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru';
const D_SUPPLIER_PLATFORM = 'B1';
// ── DETERMINISTIC SEED ────────────────────────────────────────────────────────
const D_SEED = 19;
// ── PHONE NUMBERS ─────────────────────────────────────────────────────────────
/** Phone resolving to Москва via FakeDaDataPhoneClient. */
const D_PHONE_1 = '79270000099';
beforeEach(function (): void {
// Pricing tiers — required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global RLS bypass for seeding phase (tenant context = 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config — FakeDaDataPhoneClient bypasses HTTP entirely.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Bind deterministic LeadRouter (small cap — only 2 projects, so no real lottery needed,
// but we seed for reproducibility).
app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(D_SEED))));
});
it('only delivers to the active-today client; the excluded-day client gets zero deals', function (): void {
// ── DETERMINE ACTIVE DATE AND ITS WEEKDAY BIT ────────────────────────────
//
// SnapshotForge::activeDate() mirrors LeadRouter::activeSnapshotDate().
// SnapshotRebuildCommand: weekdayBit = 1 << (date->isoWeekday() - 1).
// isoWeekday(): Monday=1 .. Sunday=7.
//
// We MUST compute the bit from the active-snapshot date (not wall-clock today),
// because after 21:00 MSK the active date flips to tomorrow.
$activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD'
$activeDateObj = Carbon::parse($activeDate, 'Europe/Moscow');
$todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4
// ACTIVE mask: full week (includes every day, including today).
$activeMask = 127; // 0b1111111
// INACTIVE mask: full week MINUS today's bit → excludes the active snapshot date.
// snapshot:rebuild WHERE (delivery_days_mask & weekdayBit) <> 0 will skip this project.
$inactiveMask = 127 & ~$todayBit; // clears the bit for the active date's weekday
// Sanity: active mask passes the filter, inactive mask does not.
expect(($activeMask & $todayBit) !== 0)->toBeTrue();
expect(($inactiveMask & $todayBit))->toBe(0);
// ── ARRANGE: SHARED SUPPLIER PROJECT ─────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => D_SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => D_SUPPLIER_DOMAIN,
]);
// ── ARRANGE: ACTIVE TENANT / PROJECT ─────────────────────────────────────
$tenantActive = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$projectActive = Project::factory()->create([
'tenant_id' => $tenantActive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $activeMask, // all days — included today
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
]);
linkProjectToSupplier($projectActive, $supplier);
// ── ARRANGE: INACTIVE TENANT / PROJECT ───────────────────────────────────
$tenantInactive = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$projectInactive = Project::factory()->create([
'tenant_id' => $tenantInactive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
]);
linkProjectToSupplier($projectInactive, $supplier);
// ── REBUILD SNAPSHOT AFTER MASKS ARE SET ─────────────────────────────────
// SnapshotRebuildCommand: WHERE (delivery_days_mask & weekdayBit) <> 0
// → projectActive IS inserted (bit set)
// → projectInactive NOT inserted (bit cleared)
SnapshotForge::rebuild();
// Verify snapshot state directly: active project is in snapshot, inactive is not.
$snapshotActiveExists = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->where('project_id', $projectActive->id)
->exists();
$snapshotInactiveExists = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->where('project_id', $projectInactive->id)
->exists();
expect($snapshotActiveExists)->toBeTrue(
"FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) " .
"was expected in the snapshot for {$activeDate} but is absent. " .
"SnapshotRebuildCommand may have a bug in delivery_days_mask filtering."
);
expect($snapshotInactiveExists)->toBeFalse(
"FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) " .
"was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. " .
"This means the delivery_days_mask filter is not working — the inactive project " .
"will receive leads it should not receive."
);
// ── ARRANGE: FAKE DADATA CLIENT ──────────────────────────────────────────
$fake = (new FakeDaDataPhoneClient())->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
// ── ACT: INJECT ONE LEAD ─────────────────────────────────────────────────
(new LeadInjector())->site(
domain: D_SUPPLIER_DOMAIN,
phone: D_PHONE_1,
tag: 'Москва',
platform: D_SUPPLIER_PLATFORM,
vid: 88_000_000_001,
);
// ── ASSERT: DEALS DISTRIBUTION ───────────────────────────────────────────
// Active client MUST receive exactly 1 deal.
$activeDeals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantActive->id)
->count();
// Inactive client MUST receive 0 deals (not in snapshot).
$inactiveDeals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenantInactive->id)
->count();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL . '=== SCENARIO D: DELIVERY DAYS FILTER REPORT ===' . PHP_EOL);
fwrite(STDOUT, sprintf('Active date: %s (isoWeekday=%d)%s',
$activeDate, $activeDateObj->isoWeekday(), PHP_EOL));
fwrite(STDOUT, sprintf('Today bit: %d (0b%s)%s',
$todayBit, str_pad(decbin($todayBit), 7, '0', STR_PAD_LEFT), PHP_EOL));
fwrite(STDOUT, sprintf('Active mask: %d (0b%s) → snapshot: %s%s',
$activeMask, str_pad(decbin($activeMask), 7, '0', STR_PAD_LEFT),
$snapshotActiveExists ? 'INCLUDED' : 'MISSING', PHP_EOL));
fwrite(STDOUT, sprintf('Inactive mask: %d (0b%s) → snapshot: %s%s',
$inactiveMask, str_pad(decbin($inactiveMask), 7, '0', STR_PAD_LEFT),
$snapshotInactiveExists ? 'PRESENT (BUG!)' : 'ABSENT (correct)', PHP_EOL));
fwrite(STDOUT, sprintf('Active client deals: %d (expected: 1)%s', $activeDeals, PHP_EOL));
fwrite(STDOUT, sprintf('Inactive client deals: %d (expected: 0)%s', $inactiveDeals, PHP_EOL));
fwrite(STDOUT, '=== END SCENARIO D ===' . PHP_EOL . PHP_EOL);
expect($activeDeals)->toBe(1,
"FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) " .
"expected 1 deal but got {$activeDeals}. " .
"Check LeadRouter eligibility query or LedgerService::chargeForDelivery."
);
expect($inactiveDeals)->toBe(0,
"FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, " .
"project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. " .
"The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. " .
"This is a correctness bug: clients with today's bit cleared MUST be absent from the snapshot."
);
})->group('imitation');
@@ -1,525 +0,0 @@
<?php
declare(strict_types=1);
use App\Mail\ZeroBalancePausedMail;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRouter;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenarios E1 / E2 / F freeze + daily-limit verification tests.
*
* VERIFICATION tests against existing prod billing+routing code.
* NOT TDD no prod code is modified. Differences vs plan reported as FINDINGs.
*
* E1: auto-pause on insufficient balance.
* Client1 balance < price of one lead InsufficientBalance on charge attempt
* project.is_active=false, ZeroBalancePausedMail queued, lead delivered to Client2.
*
* E2: frozen tenant excluded at eligibility filter stage (before any charge).
* frozen_by_balance_at IS NOT NULL LeadRouter WHERE tenants.frozen_by_balance_at IS NULL
* excludes the frozen tenant entirely; healthy Client2 gets the lead.
*
* F: daily limit reached project excluded by delivered_today >= snap.daily_limit.
* Client1 delivered_today == its snapshot daily_limit ineligible; Client2 gets the lead.
*
* Subject codes: порядковые 1..89. Москва = 82. Confirmed via RussianRegions::CODE_TO_NAME.
* Tier 1 price = 50 000 kopecks = 500 RUB (PricingTierSeeder).
*
* Task 9 Phase 1 Portal Client Imitation.
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 9
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 E1/E2/F
*/
// ── Shared constants ──────────────────────────────────────────────────────────
/** Москва subject code (порядковый, НЕ ГИБДД). */
const EF_MOSCOW_CODE = 82;
/** Domain for B1 site signal shared across all three scenarios. */
const EF_DOMAIN = 'scenario-ef-test.ru';
/** Platform prefix. */
const EF_PLATFORM = 'B1';
/** Deterministic seed — makes weighted lottery pick reproducible. */
const EF_SEED = 99;
/** Tier-1 price in RUB (first 100 leads of month): 500 RUB. */
const EF_TIER1_PRICE_RUB = '500.00';
/** Daily limit used for healthy clients — large enough to never block. */
const EF_HEALTHY_LIMIT = 50;
// ── Shared beforeEach setup ───────────────────────────────────────────────────
beforeEach(function (): void {
// Seed pricing tiers (tier 1: first 100 leads → 50 000 kopecks = 500 RUB/lead).
$this->seed(PricingTierSeeder::class);
// Allow cross-tenant reads during seeding (shared helper pattern).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config — required by LeadRegionResolver even when FakeDaDataPhoneClient is used.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Deterministic LeadRouter: Mt19937 seed so weighted pick is reproducible.
app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(EF_SEED))));
});
// ─────────────────────────────────────────────────────────────────────────────
// E1 — Auto-pause on insufficient balance
// ─────────────────────────────────────────────────────────────────────────────
it('E1: project is paused and mail sent when balance below tier price; lead goes to healthy client', function (): void {
Mail::fake();
// ── ARRANGE ──────────────────────────────────────────────────────────────
// One shared supplier project (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => EF_PLATFORM,
'signal_type' => 'site',
'unique_key' => EF_DOMAIN,
]);
// Client1: balance BELOW tier-1 price (500 RUB). Even 1 kopeck short triggers pause.
// We set balance to 0 which is definitely below 500 RUB threshold.
$tenant1 = Tenant::factory()->create([
'balance_rub' => '0.00',
'frozen_by_balance_at' => null,
]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project1, $supplier);
// Client2: healthy balance — should receive the lead.
$tenant2 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project2, $supplier);
// Snapshot: both clients eligible (positive balance + unfrozen are live-state checks,
// but snapshot itself is built before the charge; LeadRouter's SQL also checks balance > 0
// and frozen_by_balance_at IS NULL). Client1 has balance=0 so it will NOT appear in the
// router's eligibility query (balance_rub > 0 filter), making this effectively test the
// case where balance becomes 0 AFTER snapshot is built but before the charge.
//
// IMPORTANT FINDING NOTE: LeadRouter SQL WHERE tenants.balance_rub > 0 means that if
// balance is already 0 before the route call, Client1 is excluded at the query stage
// (not at the charge stage). To truly test E1 (insufficient balance at charge time),
// we must set balance to a non-zero amount that is nonetheless below the tier price.
// Tier 1 = 500 RUB. Set to 499.99 — positive but insufficient.
ConditionLevers::setBalance($tenant1, '499.99');
$activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate();
// Build snapshots for both clients so both pass the snapshot filter.
createRoutingSnapshotFromProject(
project: $project1,
date: $activeDate,
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
);
createRoutingSnapshotFromProject(
project: $project2,
date: $activeDate,
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
);
// FakeDaData: phone → Москва (qc=0, subject_code=82).
$phone = '79161234001';
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ──────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: EF_PLATFORM,
vid: 1_100_000_001,
);
// ── ASSERT ───────────────────────────────────────────────────────────────
// Client1's project must be paused (is_active=false) after the balance failure.
$project1->refresh();
expect($project1->is_active)->toBeFalse(
'FINDING E1: project1 was NOT paused after InsufficientBalance. ' .
'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. ' .
'Actual is_active=' . ($project1->is_active ? 'true' : 'false')
);
// ZeroBalancePausedMail must have been sent for tenant1 (rate-limit: first call always fires).
Mail::assertSent(ZeroBalancePausedMail::class, function (ZeroBalancePausedMail $mail) use ($tenant1): bool {
return $mail->hasTo($tenant1->contact_email);
});
// Client2 (healthy) must have received the lead.
$tenant2Deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant2->id)
->count();
expect($tenant2Deals)->toBe(1,
'FINDING E1: Client2 (healthy) did NOT receive the lead. ' .
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " .
'The auto-pause flow should skip Client1 and continue routing to Client2.'
);
// Client1 must NOT have a deal (charge rolled back).
$tenant1Deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant1->id)
->count();
expect($tenant1Deals)->toBe(0,
'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. ' .
"Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. " .
'InsufficientBalance should roll back the transaction, preventing deal creation.'
);
// lead must be marked processed.
expect($lead->processed_at)->not->toBeNull(
'FINDING E1: SupplierLead.processed_at is null — lead was not marked processed.'
);
})->group('imitation');
// ─────────────────────────────────────────────────────────────────────────────
// E2 — Frozen tenant excluded at eligibility filter (before charge)
// ─────────────────────────────────────────────────────────────────────────────
it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead', function (): void {
// ── ARRANGE ──────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => EF_PLATFORM,
'signal_type' => 'site',
'unique_key' => EF_DOMAIN,
]);
// Client1: frozen via ConditionLevers::freeze().
// After freeze(), frozen_by_balance_at IS NOT NULL → excluded by LeadRouter SQL
// WHERE tenants.frozen_by_balance_at IS NULL.
$tenant1 = Tenant::factory()->create([
'balance_rub' => '9999.00', // balance is fine — frozen is the blocker
'frozen_by_balance_at' => null,
]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project1, $supplier);
// Freeze Client1 BEFORE snapshot rebuild.
// LeadRouter SQL: AND tenants.frozen_by_balance_at IS NULL
// Frozen clients are excluded at the query stage — no charge attempt is made.
ConditionLevers::freeze($tenant1);
// Client2: healthy.
$tenant2 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project2, $supplier);
$activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate();
// Snapshots for both clients. The snapshot itself may include Client1 (snapshot was
// built from static project data), but LeadRouter's SQL live-checks frozen_by_balance_at.
createRoutingSnapshotFromProject(
project: $project1,
date: $activeDate,
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
);
createRoutingSnapshotFromProject(
project: $project2,
date: $activeDate,
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
);
$phone = '79161234002';
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ──────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: EF_PLATFORM,
vid: 1_100_000_002,
);
// ── ASSERT ───────────────────────────────────────────────────────────────
// Client1 (frozen) must have ZERO deals — excluded at filter stage.
$tenant1Deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant1->id)
->count();
expect($tenant1Deals)->toBe(0,
'FINDING E2: Frozen client (tenant1) received a deal. ' .
"Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " .
'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. ' .
'This is a serious billing/freeze bug.'
);
// Client2 (healthy) must have received the lead.
$tenant2Deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant2->id)
->count();
expect($tenant2Deals)->toBe(1,
'FINDING E2: Healthy Client2 did NOT receive the lead. ' .
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " .
'With frozen Client1 excluded, Client2 should be the sole eligible recipient.'
);
// Verify tenant1 IS still frozen (no accidental unfreeze by any path).
$tenant1->refresh();
expect($tenant1->frozen_by_balance_at)->not->toBeNull(
'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. ' .
'Freeze state must be preserved across the routing cycle.'
);
// lead processed.
expect($lead->processed_at)->not->toBeNull(
'FINDING E2: SupplierLead.processed_at is null — lead was not marked processed.'
);
})->group('imitation');
// ─────────────────────────────────────────────────────────────────────────────
// F — Daily limit reached: project excluded from eligibility
// ─────────────────────────────────────────────────────────────────────────────
it('F: client at daily limit is excluded; lead goes to client with remaining capacity', function (): void {
// ── ARRANGE ──────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => EF_PLATFORM,
'signal_type' => 'site',
'unique_key' => EF_DOMAIN,
]);
$dailyLimit = 5; // small limit so fillToLimit is clear
// Client1: delivered_today EQUAL to daily_limit → excluded (delivered_today < daily_limit is false).
$tenant1 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => $dailyLimit,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project1, $supplier);
// Client2: healthy with headroom.
$tenant2 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project2, $supplier);
$activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate();
// Build snapshots BEFORE setting delivered_today to limit,
// so both clients appear in the snapshot.
createRoutingSnapshotFromProject(
project: $project1,
date: $activeDate,
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: $dailyLimit,
regions: '{' . EF_MOSCOW_CODE . '}',
);
createRoutingSnapshotFromProject(
project: $project2,
date: $activeDate,
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
);
// NOW set Client1 delivered_today = limit (5) so it fails the eligibility check:
// LeadRouter SQL: AND projects.delivered_today < snap.daily_limit
// Also the inner createDealCopyForProject recheck: $lockedProject->delivered_today >= $effectiveLimit
ConditionLevers::fillToLimit($project1);
$phone = '79161234003';
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ──────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: EF_PLATFORM,
vid: 1_100_000_003,
);
// ── ASSERT ───────────────────────────────────────────────────────────────
// Client1 (at limit) must have ZERO deals.
$tenant1Deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant1->id)
->count();
expect($tenant1Deals)->toBe(0,
'FINDING F: Client1 (at daily limit) received a deal. ' .
"Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " .
"LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. " .
"project1.daily_limit_target={$dailyLimit}, delivered_today should be {$dailyLimit} after fillToLimit."
);
// Client2 (has headroom) must receive exactly 1 deal.
$tenant2Deals = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant2->id)
->count();
expect($tenant2Deals)->toBe(1,
'FINDING F: Client2 (with headroom) did NOT receive the lead. ' .
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " .
'With Client1 at limit and excluded, Client2 should be the sole eligible recipient.'
);
// Verify project1 delivered_today is still at limit (nothing was delivered to it).
$project1->refresh();
expect((int) $project1->delivered_today)->toBe($dailyLimit,
'FINDING F: project1.delivered_today changed during routing. ' .
"Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. " .
'A lead was delivered to a project that exceeded its limit.'
);
// project1 is_active should still be true — limit exhaustion alone does NOT trigger
// auto-pause (only InsufficientBalance does). This is a deliberate design check.
expect($project1->is_active)->toBeTrue(
'FINDING F: project1.is_active was set to false due to limit exhaustion. ' .
'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. ' .
'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). ' .
'If this fails: auto-pause is being triggered by the wrong condition — report as bug.'
);
// lead processed.
expect($lead->processed_at)->not->toBeNull(
'FINDING F: SupplierLead.processed_at is null — lead was not marked processed.'
);
})->group('imitation');
@@ -1,291 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenario G3 «осиротевшая» заявка (orphan lead): все три фазы маршрутизации
* возвращают пустой результат никто не eligible.
*
* Спек: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 G3
* План: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 10
*
* VERIFICATION test against existing prod routing code (LeadRouter + RouteSupplierLeadJob).
* NOT TDD no prod code is modified. Differences vs plan reported as FINDINGs.
*
* Сценарий:
* - Один SupplierProject (B1, site-сигнал).
* - Три клиента (проекта), каждый по-своему негоден:
* P1: лимит исчерпан (fillToLimit) выбывает из всех трёх фаз SQL-фильтром
* delivered_today < snap.daily_limit.
* P2: заморожен (tenants.frozen_by_balance_at IS NOT NULL) выбывает через
* tenant-фильтр LeadRouter; snapshot есть (фаза 3 всё равно не возьмёт).
* P3: баланс = 0 (tenants.balance_rub = 0) выбывает через balance_rub > 0;
* snapshot есть (фаза 3 всё равно не возьмёт).
* - Регион лида Москва (code 82, qc=0, via FakeDaDataPhoneClient).
* - Регион всех трёх проектов в snapshot Москва (код 82) это важно, чтобы
* фаза 1 могла бы найти их, если бы не другие барьеры; при этом phase 3
* (any-region) тоже не найдёт те же tenant/limit барьеры работают во всех фазах.
*
* Ожидания (из плана §Task 10):
* - deals created = 0;
* - lead_charges = 0, balance_transactions = 0 (деньги не тронуты);
* - SupplierLead.processed_at IS NOT NULL (job завершился);
* - SupplierLead.deals_created_count = 0;
* - NO exception (job не упал);
* - Непроданный лид виден в supplier_leads.
*/
const G3_MOSCOW_CODE = 82;
const G3_SUPPLIER_DOMAIN = 'scenario-g3-orphan.ru';
const G3_SUPPLIER_PLATFORM = 'B1';
const G3_DAILY_LIMIT = 5;
const G3_LEAD_PHONE = '79161234599';
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Bind FakeDaDataPhoneClient: lead's phone resolves to Москва (qc=0, code=82).
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub(G3_LEAD_PHONE, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
});
it('orphan lead: no deals created, processed_at set, no exception, no money moved', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
// One shared SupplierProject (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => G3_SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => G3_SUPPLIER_DOMAIN,
]);
$activeDate = SnapshotForge::activeDate();
// ── Client P1: limit exhausted — fillToLimit makes delivered_today = daily_limit_target.
// queryCandidates: projects.delivered_today < snap.daily_limit → FALSE → excluded from all phases.
$tenantP1 = Tenant::factory()->create([
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP1 = Project::factory()->create([
'tenant_id' => $tenantP1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP1, $supplier);
createRoutingSnapshotFromProject(
project: $projectP1,
date: $activeDate,
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{' . G3_MOSCOW_CODE . '}',
);
// Exhaust the limit: delivered_today = G3_DAILY_LIMIT → SQL condition false in all phases.
ConditionLevers::fillToLimit($projectP1);
// ── Client P2: tenant frozen (frozen_by_balance_at IS NOT NULL).
// queryCandidates: WHERE tenants.frozen_by_balance_at IS NULL → FALSE → excluded from all phases.
$tenantP2 = Tenant::factory()->create([
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP2 = Project::factory()->create([
'tenant_id' => $tenantP2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP2, $supplier);
createRoutingSnapshotFromProject(
project: $projectP2,
date: $activeDate,
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{' . G3_MOSCOW_CODE . '}',
);
// Freeze the tenant: frozen_by_balance_at IS NOT NULL → excluded from all phases.
ConditionLevers::freeze($tenantP2);
// ── Client P3: zero balance (balance_rub = 0).
// queryCandidates: WHERE tenants.balance_rub > 0 → FALSE → excluded from all phases.
$tenantP3 = Tenant::factory()->create([
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP3 = Project::factory()->create([
'tenant_id' => $tenantP3->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP3, $supplier);
createRoutingSnapshotFromProject(
project: $projectP3,
date: $activeDate,
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{' . G3_MOSCOW_CODE . '}',
);
// Drain balance: balance_rub = 0 → balance_rub > 0 condition fails in all phases.
ConditionLevers::drainBalance($tenantP3);
// Record counts BEFORE injection to detect any pre-existing rows.
$dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count();
$chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count();
$balanceTxCountBefore = DB::connection('pgsql_supplier')->table('balance_transactions')->count();
// ── ACT — inject one lead and run the job synchronously ─────────────────────
// We wrap in try/catch to detect exceptions — the plan says NO exception should bubble.
$thrownException = null;
$injectedLead = null;
try {
$injector = new LeadInjector();
$injectedLead = $injector->site(
domain: G3_SUPPLIER_DOMAIN,
phone: G3_LEAD_PHONE,
tag: 'Москва',
platform: G3_SUPPLIER_PLATFORM,
vid: 8_888_000_001,
);
} catch (\Throwable $e) {
$thrownException = $e;
}
// ── ASSERT ──────────────────────────────────────────────────────────────────
// 1. No exception bubbled — the job must complete cleanly.
expect($thrownException)->toBeNull(
'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. ' .
'Expected: no exception. Got: ' . ($thrownException?->getMessage() ?? 'none')
);
// 2. The SupplierLead was created and is accessible.
expect($injectedLead)->not->toBeNull(
'FINDING: LeadInjector returned null — SupplierLead was not created.'
);
// Re-fetch fresh from DB to get updated processed_at / deals_created_count.
/** @var SupplierLead $freshLead */
$freshLead = SupplierLead::find($injectedLead->id);
expect($freshLead)->not->toBeNull(
'FINDING: SupplierLead id=' . $injectedLead->id . ' not found in DB after injection.'
);
// 3. processed_at IS set — job stamped the lead as "processed" even though nobody received it.
// WHERE: supplier_leads table, column processed_at — this is WHERE the orphan lead "rests".
expect($freshLead->processed_at)->not->toBeNull(
'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. ' .
'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, ' .
'even when deals_created_count=0. The orphan lead should rest in supplier_leads ' .
'with processed_at set (idempotency guard).'
);
// 4. deals_created_count = 0 — no deals were created.
expect((int) $freshLead->deals_created_count)->toBe(0,
'FINDING: SupplierLead.deals_created_count expected 0 but got ' .
$freshLead->deals_created_count . '. ' .
'No eligible project was found — no deal should have been created.'
);
// 5. No deals created across all three tenants.
$newDealsCount = DB::connection('pgsql_supplier')->table('deals')->count() - $dealsCountBefore;
expect($newDealsCount)->toBe(0,
'FINDING: ' . $newDealsCount . ' deal(s) were created despite all clients being ineligible. ' .
'P1(limit-exhausted), P2(frozen), P3(zero-balance) should all fail LeadRouter SQL filter.'
);
// 6. No lead_charges created — no money moved.
$newChargesCount = DB::connection('pgsql_supplier')->table('lead_charges')->count() - $chargesCountBefore;
expect($newChargesCount)->toBe(0,
'FINDING: ' . $newChargesCount . ' lead_charge row(s) created despite no eligible clients. ' .
'LedgerService::chargeForDelivery should not have been called.'
);
// 7. No balance_transactions created — balances untouched.
$newBalanceTxCount = DB::connection('pgsql_supplier')->table('balance_transactions')->count() - $balanceTxCountBefore;
expect($newBalanceTxCount)->toBe(0,
'FINDING: ' . $newBalanceTxCount . ' balance_transaction(s) created despite no eligible clients.'
);
// 8. The orphan lead is visible/recorded — WHERE it rests.
// It lives in `supplier_leads` with processed_at IS NOT NULL and deals_created_count = 0.
$orphanCount = DB::table('supplier_leads')
->where('id', $freshLead->id)
->whereNotNull('processed_at')
->where('deals_created_count', 0)
->count();
expect($orphanCount)->toBe(1,
'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and ' .
'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by ' .
'processed_at IS NOT NULL + deals_created_count = 0 (no error column set).'
);
// ── REPORT ──────────────────────────────────────────────────────────────────
fwrite(STDOUT, PHP_EOL . '=== SCENARIO G3 ORPHAN LEAD REPORT ===' . PHP_EOL);
fwrite(STDOUT, 'SupplierLead id: ' . $freshLead->id . PHP_EOL);
fwrite(STDOUT, 'processed_at: ' . ($freshLead->processed_at?->toIso8601String() ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, 'deals_created_count: ' . $freshLead->deals_created_count . PHP_EOL);
fwrite(STDOUT, 'error: ' . ($freshLead->error ?? 'NULL (no error)') . PHP_EOL);
fwrite(STDOUT, 'deals created (new): ' . $newDealsCount . PHP_EOL);
fwrite(STDOUT, 'lead_charges (new): ' . $newChargesCount . PHP_EOL);
fwrite(STDOUT, 'balance_transactions(new):' . $newBalanceTxCount . PHP_EOL);
fwrite(STDOUT, PHP_EOL . 'WHERE the orphan lead rests:' . PHP_EOL);
fwrite(STDOUT, ' Table: supplier_leads' . PHP_EOL);
fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0' . PHP_EOL);
fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).' . PHP_EOL);
fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).' . PHP_EOL);
fwrite(STDOUT, '=== END G3 REPORT ===' . PHP_EOL . PHP_EOL);
})->group('imitation');
@@ -1,388 +0,0 @@
<?php
declare(strict_types=1);
/**
* Verification tests ScenarioG5G6_SpecialLeadsTest (Task 11, Phase 1 imitation).
*
* PROVING tests against existing production code.
* Differences vs plan FINDINGS captured in test comments.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 11
* Spec: §6.4 G5a/b/c + G6
*
* Key verified facts (from reading prod code and RegionResolverCascadeTest findings):
*
* G5a FINDING: Plan says qc=2/7 source='tag' immediately.
* Actual: LeadRegionResolver::doResolve() line 101-103 calls tagFallback() for qc=2/7.
* tagFallback() returns source='tag' ONLY when tagCode !== null (i.e. tag is a valid region).
* Empty/null tag tagCode=null source='unknown'. Confirmed by RegionResolverCascadeTest F2.
* This test asserts REAL behaviour: empty tag 'unknown', valid tag 'tag'.
*
* G5b: DaData throws (stubThrows) OR qc=1 falls through to Россвязь.
* Source='rossvyaz' when phone_ranges row seeded and subscriber in range.
* Phone parsing: phone=7{defCode}{subscriber}, e.g. 79885550011 defCode=988, subscriber=5550011.
*
* G5c: qc=2 + no seeded range + empty tag source='unknown'.
* (Россвязь is not called at all for qc=2 it goes straight to tagFallback.)
*
* G6 (dedup the key new case in this file):
* Dedup is implemented in SupplierWebhookController::receive(), NOT in LeadInjector.
* Two HTTP POST requests with the same vid:
* First 202 + body.status='accepted' + SupplierLead created.
* Second 200 + body.status='already_processed' + body.supplier_lead_id = existing id.
* Verified from controller code lines 94-100.
* supplier_leads count for that vid stays 1.
*
* G5 tests use LeadRegionResolver directly (unit-style cascade tests).
* G6 test uses HTTP POST through the real controller route (the only dedup path).
*
* Dedup response shape (exact, from controller lines 94-100):
* HTTP 200
* { "status": "already_processed", "supplier_lead_id": <int> }
*
* First-request response shape (from controller lines 116-119):
* HTTP 202
* { "status": "accepted", "supplier_lead_id": <int> }
*/
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
uses(DatabaseTransactions::class, SharesSupplierPdo::class)->group('imitation');
// ---------------------------------------------------------------------------
// Webhook secret for G6 HTTP-layer dedup tests
// ---------------------------------------------------------------------------
const G5G6_SECRET = 'g5g6-test-secret-32chars-aaaaaab'; // exactly 32 chars (controller requires strlen >= 32)
// ---------------------------------------------------------------------------
// beforeEach — shared state for all tests in this file
// ---------------------------------------------------------------------------
beforeEach(function (): void {
// Tenant context bypass for cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Default: DaData disabled (individual tests enable it as needed).
config(['services.dadata.enabled' => false]);
// Fix for worktree-local .env placeholder APP_KEY — the default value
// 'base64:testingkeyplaceholderxxxxxxxxxxxxxxxo=' is not a valid AES-256-CBC key
// (wrong decoded length). Inject a valid 32-byte base64-encoded key so that
// Laravel's Encrypter does not throw on HTTP test requests.
// The main project's .env has a real key; phpunit.xml in this worktree has no APP_KEY.
// This fix is scoped to this file's tests only (config() changes are per-request).
if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) {
config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]);
app('encrypter')->__construct(
str_repeat('a', 32),
config('app.cipher', 'AES-256-CBC')
);
}
// Set a known webhook secret for G6 tests.
SystemSetting::query()
->where('key', 'supplier_webhook_secret')
->update(['value' => G5G6_SECRET]);
// IP allowlist empty → fail-open in testing env (verifyIpAllowlist returns true
// for non-production environments when allowlist is empty — controller line 177).
SystemSetting::query()
->where('key', 'supplier_ip_allowlist')
->update(['value' => '[]']);
// Seed pricing tiers (required by RouteSupplierLeadJob/LedgerService path).
try {
(new \Database\Seeders\PricingTierSeeder())->run();
} catch (\Throwable) {
// Already seeded or not needed for this specific test.
}
});
// ---------------------------------------------------------------------------
// Helper: create a SupplierLead with a given phone + tag for resolver tests
// ---------------------------------------------------------------------------
function makeG5Lead(string $phone, string $tag = ''): SupplierLead
{
$sp = SupplierProject::factory()->create();
return SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
]);
}
// ---------------------------------------------------------------------------
// Helper: insert a phone_ranges row correctly (same as RegionResolverCascadeTest)
// ---------------------------------------------------------------------------
function insertG5PhoneRange(int $defCode, int $from, int $to, int $subjectCode): void
{
$importId = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://rossvyaz-g5g6',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', "g5g6-{$defCode}-{$from}-{$to}-{$subjectCode}"),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized' => null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
]);
}
// ===========================================================================
// G5a — qc=2 (мусор) / qc=7 (иностранец) → tag-fallback immediately
// (Россвязь is NOT consulted)
// ===========================================================================
it('G5a: qc=2 + empty tag → source=unknown (FINDING: plan says tag, actual is unknown for empty tag)', function (): void {
// FINDING: The plan §6.4 G5a says "source='tag' immediately" for qc=2/7.
// Reality: resolver calls tagFallback(); with empty tag, tagCode=null → source='unknown'.
// This was also found and documented as F2 in RegionResolverCascadeTest.
// We assert REAL behaviour.
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000201', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000201', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
// REAL behaviour: empty tag → tagCode=null → source='unknown', NOT 'tag'
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->qc)->toBe(2)
->and($res->rossvyazMatched)->toBeFalse(); // Россвязь skipped for qc=2
})->group('imitation');
it('G5a: qc=2 + valid tag → source=tag (Россвязь still skipped)', function (): void {
// When qc=2 and tag resolves to a known region: source='tag'.
// Россвязь is never consulted for qc=2 (the code path jumps to tagFallback).
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000202', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$moscowCode = RussianRegions::nameToCode()['Москва'];
$lead = makeG5Lead('79990000202', tag: 'Москва');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('tag')
->and($res->subjectCode)->toBe($moscowCode)
->and($res->qc)->toBe(2)
->and($res->rossvyazMatched)->toBeFalse(); // Россвязь skipped for qc=2
})->group('imitation');
it('G5a: qc=7 + empty tag → source=unknown (same as qc=2, Россвязь skipped)', function (): void {
// qc=7 (иностранец) behaves identically to qc=2: goes straight to tagFallback().
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000203', qc: 7, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000203', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->qc)->toBe(7)
->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');
// ===========================================================================
// G5b — DaData unavailable (stubThrows → DaDataException) OR qc=1
// + seeded phone_ranges range → source='rossvyaz'
// ===========================================================================
it('G5b: DaData throws DaDataException + seeded phone range → source=rossvyaz, rossvyazMatched=true', function (): void {
// DaData network failure / 5xx → resolver catches DaDataException → falls to Россвязь.
// With a seeded phone_ranges row matching the phone → source='rossvyaz'.
//
// Phone 79885550211: def_code=988, subscriber=5550211
config(['services.dadata.enabled' => true]);
$tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // ordinal 77
insertG5PhoneRange(defCode: 988, from: 5550000, to: 5559999, subjectCode: $tyumenCode);
$fake = (new FakeDaDataPhoneClient())->stubThrows('79885550211');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79885550211', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
->and($res->subjectCode)->toBe($tyumenCode);
})->group('imitation');
it('G5b: qc=1 (не уточнён) + seeded phone range → source=rossvyaz, rossvyazMatched=true', function (): void {
// qc=1 falls through the qc=0/3 block and the qc=2/7 block → arrives at Россвязь step.
// With a seeded phone range → source='rossvyaz'.
//
// Phone 79886660311: def_code=988, subscriber=6660311
config(['services.dadata.enabled' => true]);
$voronezCode = RussianRegions::nameToCode()['Воронежская область']; // ordinal 42
insertG5PhoneRange(defCode: 988, from: 6660000, to: 6669999, subjectCode: $voronezCode);
$fake = (new FakeDaDataPhoneClient())->stub('79886660311', qc: 1, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79886660311', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
->and($res->subjectCode)->toBe($voronezCode);
})->group('imitation');
// ===========================================================================
// G5c — neither DaData (qc=2 routes to tagFallback) nor Россвязь range seeded,
// and empty tag → source='unknown'
// ===========================================================================
it('G5c: qc=2 + no phone range seeded + empty tag → source=unknown', function (): void {
// For qc=2, Россвязь is not consulted at all (code jumps straight to tagFallback).
// Empty tag → tagCode=null → source='unknown'.
// This is a pure tagFallback outcome with no external source resolved.
config(['services.dadata.enabled' => true]);
// No phone_ranges seeded for this phone.
$fake = (new FakeDaDataPhoneClient())->stub('79990000304', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000304', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');
it('G5c: DaData throws + no phone range seeded + empty tag → source=unknown', function (): void {
// DaData exception path: resolver falls to Россвязь, but no range is seeded.
// Россвязь returns null → falls to tagFallback with empty tag → source='unknown'.
config(['services.dadata.enabled' => true]);
// No phone_ranges seeded for this phone.
$fake = (new FakeDaDataPhoneClient())->stubThrows('79990000305');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000305', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
->and($res->rossvyazMatched)->toBeFalse();
})->group('imitation');
// ===========================================================================
// G6 — VID dedup: same vid injected twice via HTTP controller
// First → 202 + status='accepted'
// Second → 200 + status='already_processed' + same supplier_lead_id
// supplier_leads count for that vid stays exactly 1
// ===========================================================================
it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_processed, count stays 1', function (): void {
// Dedup is enforced in SupplierWebhookController::receive() lines 94-100.
// The UNIQUE INDEX on supplier_leads.vid prevents a second INSERT.
// The controller checks for existence BEFORE INSERT and returns early:
// if ($existing !== null) { return response()->json(['status' => 'already_processed', ...], 200); }
//
// We use the HTTP layer (not LeadInjector) because LeadInjector bypasses the controller.
// RouteSupplierLeadJob is faked to prevent actual routing which requires full snapshot setup.
Bus::fake();
$vid = 987654321; // Deterministic vid, outside auto-generated ranges from LeadInjector
$payload = [
'vid' => $vid,
'project' => 'B1_g6test.example.com',
'phone' => '79991112233',
'time' => time(),
'tag' => 'Москва',
];
// First request → should be accepted (202).
$first = $this->postJson(
'/api/webhook/supplier/' . G5G6_SECRET,
$payload
);
$first->assertStatus(202);
$first->assertJson(['status' => 'accepted']);
$firstLeadId = $first->json('supplier_lead_id');
expect($firstLeadId)->toBeInt()->toBeGreaterThan(0);
// Verify exactly 1 SupplierLead row exists for this vid.
expect(SupplierLead::where('vid', $vid)->count())->toBe(1);
// Second request — same vid, same payload → dedup path (200).
$second = $this->postJson(
'/api/webhook/supplier/' . G5G6_SECRET,
$payload
);
$second->assertStatus(200);
// Exact response shape from controller lines 96-99:
// { "status": "already_processed", "supplier_lead_id": <existing id> }
$second->assertJson([
'status' => 'already_processed',
'supplier_lead_id' => $firstLeadId,
]);
// supplier_leads count MUST still be 1 — no second row was created.
expect(SupplierLead::where('vid', $vid)->count())->toBe(1);
// RouteSupplierLeadJob dispatched exactly once (for the first request).
// The second request returns early before any dispatch.
Bus::assertDispatchedTimes(\App\Jobs\RouteSupplierLeadJob::class, 1);
})->group('imitation');
it('G6: second request returns the SAME supplier_lead_id as the first', function (): void {
// Focused assertion: the already_processed response echoes back the original id,
// not a new one. This guards against a hypothetical bug where a new row was inserted
// despite the dedup check (e.g. race) — though the UNIQUE INDEX prevents it at DB level.
Bus::fake();
$vid = 987654322; // Different deterministic vid from G6 test above
$payload = [
'vid' => $vid,
'project' => 'B1_g6test.example.com',
'phone' => '79991112244',
'time' => time(),
];
$first = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload);
$second = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload);
$first->assertStatus(202);
$second->assertStatus(200);
expect($second->json('supplier_lead_id'))->toBe($first->json('supplier_lead_id'));
expect(SupplierLead::where('vid', $vid)->count())->toBe(1);
})->group('imitation');
@@ -1,513 +0,0 @@
<?php
declare(strict_types=1);
/**
* Verification tests ScenarioX1X3_SubstitutionJournalTest (Task 12, Phase 1 imitation).
*
* PROVING tests against existing production routing code.
* Differences vs plan FINDINGS captured in test output and comments.
* NO production code is modified. Only this one file is created.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md
* "Task 12: X1 — подмена региона на шаге 3 + журнал; X3 — сводка источника"
* Spec: §6.5 X1/X3 + §7 п.30/41
*
* ── KEY PRODUCTION CODE FACTS (verified from reading prod code, NOT guessing) ────
*
* RouteSupplierLeadJob::createDealCopyForProject() lines 433453:
* $dealSubjectCode = ($routingStep < 3)
* ? $resolution->subjectCode
* : (pickSubstituteRegion($snapshot->regions ?? '{}') ?? $resolution->subjectCode);
* Deal::create([
* 'subject_code' => $dealSubjectCode, // substituted (client's) on step 3
* 'city' => CODE_TO_NAME[$resolution->subjectCode] ?? null, // REAL lead region ALWAYS
* 'region_substituted' => ($routingStep === 3), // flag
* ]);
*
* RouteSupplierLeadJob::logRegionResolution() lines 558595:
* $substituted = ($routingStep === 3 && $first !== null)
* ? (pickSubstituteRegion($first->snapshot_regions ?? '{}') ?? $resolution->subjectCode)
* : null;
* INSERT lead_region_resolution_log {
* actual_subject_code => $resolution->actualSubjectCode // real resolved code
* substituted_subject_code=> $substituted // client's code on step 3, else null
* routing_step => $routingStep // step of FIRST selected project
* subject_code_resolved => $resolution->subjectCode // real resolved code (same as actual)
* }
*
* LeadRouter: phase 3 fires ONLY when combined(phase1+phase2) is EMPTY.
* To force step 3: the ONLY eligible client must have an exact region DIFFERENT
* from the lead's resolved region, AND no all-RF client (regions='{}').
* phase 1 empty (exact mismatch), phase 2 empty (no '{}' client), phase 3 fires.
*
* pickSubstituteRegion() picks the FIRST int from the PG INT[]-literal '{R_client}'.
* snapshot.regions column holds the client's subscribed regions.
*
* RegionResolution.actualSubjectCode = subjectCode at construction (RegionResolution::make()).
* They are equal at the resolver stage; substitution is a RouteSupplierLeadJob concern only.
*
* X3 region_source values:
* 'dadata' FakeDaData stub with qc=0
* 'rossvyaz' FakeDaData stubThrows + seeded phone_ranges row matching the phone
* 'tag' DaData disabled OR qc=2/7 + valid tag string (FINDING: qc=2 with valid tag 'tag')
* 'unknown' DaData disabled/fails + no phone_range + empty/null tag
*
* X3 uses direct LeadRegionResolver::resolve() calls (not full routing) to
* produce multiple resolution log rows cheaply via separate SupplierLead rows.
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ────────────────────────────
/** RussianRegions::CODE_TO_NAME[29] = 'Красноярский край' — real lead region */
const X1_LEAD_REGION = 29;
/** RussianRegions::CODE_TO_NAME[37] = 'Белгородская область' — client's subscribed region */
const X1_CLIENT_REGION = 37;
/** RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const X1_MOSCOW = 82;
/** Domains for X1 and X3 tests (B1 site signal, unique per scenario to avoid snapshot collisions) */
const X1_DOMAIN = 'scenario-x1-substitution.ru';
const X3_DOMAIN = 'scenario-x3-source-breakdown.ru';
/** Deterministic seed for LeadRouter */
const X1_SEED = 42;
// ── SHARED beforeEach ──────────────────────────────────────────────────────────
beforeEach(function (): void {
// Pricing tiers required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global RLS bypass for seeding (tenant context = 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config defaults — individual tests override as needed.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Deterministic LeadRouter — seeded Mt19937. With 1 candidate, weightedPick
// always returns it (pool ≤ cap=3 so no lottery needed), but seeded for stability.
app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(X1_SEED))));
});
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X1 — step-3 substitution: subject_code, city, journal actual/substituted
// ══════════════════════════════════════════════════════════════════════════════
/**
* X1: One client subscribed to R_client (Белгородская обл., code 37).
* No all-RF client. Lead resolved to R_lead (Красноярский край, code 29) via DaData qc=0.
*
* Cascade:
* Phase 1 exact: 29 NOT in client's regions={37} empty.
* Phase 2 all-RF: no '{}' client empty.
* Phase 3 fallback: client eligible (any region) routing_step=3.
*
* Expected (prod spec §3.10 + §7 п.30/41):
* deals.subject_code = R_client = 37 (substituted to client's region)
* deals.city = name(R_lead) = 'Красноярский край' (REAL lead region)
* deals.region_substituted = true
* lead_region_resolution_log.actual_subject_code = R_lead = 29
* lead_region_resolution_log.substituted_subject_code = R_client = 37
* lead_region_resolution_log.subject_code_resolved = R_lead = 29
* lead_region_resolution_log.routing_step = 3
*/
it('X1: step-3 fallback substitutes subject_code to client region, preserves real region in city + journal', function (): void {
// ── ARRANGE ───────────────────────────────────────────────────────────────
// One supplier (B1 site).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X1_DOMAIN,
]);
// One client: subscribed to R_client=37 (Белгородская обл.) ONLY.
// No all-RF client → phases 1+2 both empty → phase 3 fires.
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => X1_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [X1_CLIENT_REGION], // {37}
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: X1_DOMAIN,
dailyLimit: 10,
regions: '{' . X1_CLIENT_REGION . '}', // '{37}'
);
// Lead resolves to R_lead=29 (Красноярский край) via DaData qc=0.
// DaData region string must EXACTLY match RussianRegions::CODE_TO_NAME[29]
// for DaDataRegionMap::toSubjectCode() to return code 29.
$leadPhone = '79292900001';
$leadRegionName = RussianRegions::CODE_TO_NAME[X1_LEAD_REGION]; // 'Красноярский край'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub($leadPhone, qc: 0, region: $leadRegionName, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ───────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: X1_DOMAIN,
phone: $leadPhone,
tag: null,
platform: 'B1',
vid: 9_120_001_001,
);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Retrieve the deal for our tenant.
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->first();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL . '=== X1 SUBSTITUTION ===' . PHP_EOL);
fwrite(STDOUT, "Lead phone: {$leadPhone}" . PHP_EOL);
fwrite(STDOUT, "R_lead (real resolved): " . X1_LEAD_REGION . " ({$leadRegionName})" . PHP_EOL);
fwrite(STDOUT, "R_client (client region): " . X1_CLIENT_REGION . " (" . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . ")" . PHP_EOL);
fwrite(STDOUT, "deal found: " . ($deal !== null ? 'YES' : 'NO') . PHP_EOL);
if ($deal !== null) {
fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}" . PHP_EOL);
fwrite(STDOUT, "deals.city: {$deal->city}" . PHP_EOL);
fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}" . PHP_EOL);
}
if ($logRow !== null) {
fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}" . PHP_EOL);
fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}" . PHP_EOL);
fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.region_source: {$logRow->region_source}" . PHP_EOL);
} else {
fwrite(STDOUT, "log row: NOT FOUND" . PHP_EOL);
}
fwrite(STDOUT, '=== END X1 ===' . PHP_EOL . PHP_EOL);
// A deal must have been created.
expect($deal)->not->toBeNull(
'FINDING: No deal was created for the tenant. ' .
'The phase-3 fallback (any region) may not be reaching this client, ' .
'or the snapshot is missing.'
);
if ($deal !== null) {
// deals.subject_code must be R_client (substituted to client's region on step 3).
expect((int) $deal->subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: deals.subject_code should be ' . X1_CLIENT_REGION . ' (R_client, Белгородская обл.) ' .
'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. ' .
'Got: ' . $deal->subject_code . '. ' .
'If got R_lead=' . X1_LEAD_REGION . ': substitution is not firing (routingStep capture or pickSubstituteRegion failed). ' .
'If got null: snapshot.regions may not be picked up correctly.'
);
// deals.city must be the name of R_lead (real resolved region), NOT R_client.
// Code §3.10 comment: «Город» = человекочитаемое имя НАСТОЯЩЕГО региона лида.
expect($deal->city)->toBe($leadRegionName,
'FINDING: deals.city should be "' . $leadRegionName . '" (name of R_lead=' . X1_LEAD_REGION . ', real lead region). ' .
'Got: "' . $deal->city . '". ' .
'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. ' .
'If city = "' . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . '": ' .
'prod code erroneously uses $dealSubjectCode for city.'
);
// deals.region_substituted must be true.
// PostgreSQL boolean comes back as string '1'/'0'/'t'/'f' or bool depending on driver.
$regionSubstituted = filter_var($deal->region_substituted, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($regionSubstituted === null) {
// Raw value from DB — accept truthy string/int representations.
$regionSubstituted = in_array($deal->region_substituted, [true, 1, '1', 't', 'true'], true);
}
expect($regionSubstituted)->toBeTrue(
'FINDING: deals.region_substituted should be TRUE on routing_step=3. ' .
'Got raw value: "' . $deal->region_substituted . '". ' .
'Check RouteSupplierLeadJob line: \'region_substituted\' => $routingStep === 3.'
);
}
// lead_region_resolution_log must have a row.
expect($logRow)->not->toBeNull(
'FINDING: lead_region_resolution_log has no row for this lead. ' .
'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). ' .
'Check for partition missing (received_at date not matching any partition).'
);
if ($logRow !== null) {
// routing_step = 3.
expect((int) $logRow->routing_step)->toBe(3,
'FINDING: log.routing_step should be 3 (phase-3 fallback). ' .
'Got: ' . $logRow->routing_step . '. ' .
'If 1: exact match fired (snapshot.regions may be wrong). ' .
'If 2: all-RF match fired (client has regions=\'{}\' or snapshot is wrong).'
);
// subject_code_resolved = R_lead (the actual resolved code, not substituted).
expect((int) $logRow->subject_code_resolved)->toBe(X1_LEAD_REGION,
'FINDING: log.subject_code_resolved should be ' . X1_LEAD_REGION . ' (R_lead, real resolved). ' .
'Got: ' . $logRow->subject_code_resolved . '.'
);
// actual_subject_code = R_lead.
// RegionResolution.actualSubjectCode = subjectCode at construction time (real resolved).
expect((int) $logRow->actual_subject_code)->toBe(X1_LEAD_REGION,
'FINDING: log.actual_subject_code should be ' . X1_LEAD_REGION . ' (R_lead, real lead region). ' .
'Got: ' . $logRow->actual_subject_code . '. ' .
'actualSubjectCode is set equal to subjectCode in RegionResolution::make().'
);
// substituted_subject_code = R_client (the first code from snapshot.regions).
// pickSubstituteRegion('{37}') → 37 = X1_CLIENT_REGION.
expect($logRow->substituted_subject_code)->not->toBeNull(
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client) on step 3. ' .
'Got null. ' .
'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. ' .
'Check that $first->snapshot_regions attribute is present (set by LeadRouter SQL SELECT).'
);
if ($logRow->substituted_subject_code !== null) {
expect((int) $logRow->substituted_subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client=Белгородская обл.). ' .
'Got: ' . $logRow->substituted_subject_code . '. ' .
'pickSubstituteRegion() parses PG INT[]-literal \'{37}\' → [37] → first=37.'
);
}
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X3 — region_source breakdown (dadata / rossvyaz / tag / unknown)
// ══════════════════════════════════════════════════════════════════════════════
/**
* X3: Inject 4 leads with different region_source values, aggregate
* lead_region_resolution_log.region_source counts, assert they match.
*
* To avoid full routing overhead and snapshot complexity, X3 uses
* LeadRegionResolver::resolve() directly on SupplierLead rows,
* then reads region_source from the updated supplier_leads columns.
* The resolver writes region_source to supplier_leads.region_source
* (RouteSupplierLeadJob lines 159-164); the log is written by
* logRegionResolution() after routing. For X3 we inject via full
* LeadInjector (which fires RouteSupplierLeadJob) so logRegionResolution()
* also runs; however, without a client snapshot the routing loop produces
* no deals (no selected projects logRegionResolution called with empty $selected).
*
* FINDING note on log.routing_step when $selected is empty:
* logRegionResolution() line 561: $first = $selected->first() null.
* So $routingStep = null, $substituted = null.
* This is correct/expected for X3's source-breakdown scenario.
*
* Source classification (from LeadRegionResolver code):
* 'dadata' DaData enabled, qc=0 (good quality, map returns a code).
* 'rossvyaz' DaData disabled OR throws/qc=1, phone_ranges row seeded for phone.
* 'tag' DaData disabled/fails/qc=2, valid tag string (maps to a region code).
* 'unknown' DaData disabled/fails, no phone_range match, empty/null tag.
*
* We inject 1 lead per source type (4 total), then read supplier_leads.region_source.
* Counts: dadata=1, rossvyaz=1, tag=1, unknown=1.
*
* X3 uses a dedicated supplier + NO snapshot selected=empty no deals created.
* This avoids the routing infrastructure (no client setup, no snapshot needed).
* The region resolver still runs and writes region_source to supplier_leads.
*/
it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_source counts in supplier_leads', function (): void {
// ── ARRANGE ───────────────────────────────────────────────────────────────
// Supplier for X3 — NO project linked, NO snapshot → routing produces 0 deals.
// This means RouteSupplierLeadJob still runs LeadRegionResolver, updates
// supplier_leads.region_source, then calls logRegionResolution (with empty selected).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X3_DOMAIN,
]);
// ── Lead 1: source = 'dadata' ──────────────────────────────────────────────
// DaData returns qc=0 + valid region name → DaDataRegionMap maps to a code.
$phone1 = '79310000001';
$region1Name = RussianRegions::CODE_TO_NAME[X1_MOSCOW]; // 'Москва'
$fake1 = new FakeDaDataPhoneClient();
$fake1->stub($phone1, qc: 0, region: $region1Name, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake1);
config(['services.dadata.enabled' => true]);
$injector = new LeadInjector();
$lead1 = $injector->site(domain: X3_DOMAIN, phone: $phone1, tag: null, platform: 'B1', vid: 9_130_003_001);
// ── Lead 2: source = 'rossvyaz' ────────────────────────────────────────────
// DaData throws → falls through to Россвязь lookup. Seed a phone_ranges row
// covering phone2's DEF+subscriber range, mapping to subject_code=X1_MOSCOW.
// Phone format: 7{defCode}{subscriber} = 7{931}{0000002} → DEF=931, sub=0000002.
$phone2 = '79310000002';
// DEF=931, subscriber=0000002. seed range from=0 to=9999999 covering it.
$importId2 = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://x3-rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', 'x3-rossvyaz-931'),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => 931,
'from_num' => 0,
'to_num' => 9999999,
'operator' => 'test-op-x3',
'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW],
'region_normalized' => null,
'subject_code' => X1_MOSCOW,
'imported_at' => now(),
'import_id' => $importId2,
]);
$fake2 = new FakeDaDataPhoneClient();
$fake2->stubThrows($phone2); // DaData throws → cascade falls to Россвязь
app()->instance(DaDataPhoneClient::class, $fake2);
config(['services.dadata.enabled' => true]);
$lead2 = $injector->site(domain: X3_DOMAIN, phone: $phone2, tag: null, platform: 'B1', vid: 9_130_003_002);
// ── Lead 3: source = 'tag' ─────────────────────────────────────────────────
// DaData disabled → no HTTP call. No phone_range seeded for this phone.
// Tag = region name that RegionTagResolver recognises as a valid region code.
// RegionTagResolver maps tag text to a subject code. Use 'Москва' (maps to 82).
// FINDING note: qc=2 path calls tagFallback() which only returns 'tag' if tagCode != null.
// With DaData disabled, resolver falls directly to tag/rossvyaz cascade.
$phone3 = '79310000003';
config(['services.dadata.enabled' => false]);
// No DaData stub needed — disabled path skips the HTTP call entirely.
$lead3 = $injector->site(domain: X3_DOMAIN, phone: $phone3, tag: 'Москва', platform: 'B1', vid: 9_130_003_003);
// ── Lead 4: source = 'unknown' ─────────────────────────────────────────────
// DaData disabled. No phone_ranges row for this DEF. Empty tag.
// → no resolution possible → source='unknown'.
$phone4 = '79880000004'; // DEF=988, NOT seeded in phone_ranges
config(['services.dadata.enabled' => false]);
$lead4 = $injector->site(domain: X3_DOMAIN, phone: $phone4, tag: null, platform: 'B1', vid: 9_130_003_004);
// ── READ region_source from supplier_leads ────────────────────────────────
// RouteSupplierLeadJob updates supplier_leads.region_source after resolver runs.
$leadIds = [$lead1->id, $lead2->id, $lead3->id, $lead4->id];
$rows = DB::table('supplier_leads')
->whereIn('id', $leadIds)
->get(['id', 'region_source', 'resolved_subject_code', 'phone'])
->keyBy('id');
$source1 = $rows[$lead1->id]->region_source ?? 'MISSING';
$source2 = $rows[$lead2->id]->region_source ?? 'MISSING';
$source3 = $rows[$lead3->id]->region_source ?? 'MISSING';
$source4 = $rows[$lead4->id]->region_source ?? 'MISSING';
fwrite(STDOUT, PHP_EOL . '=== X3 SOURCE BREAKDOWN ===' . PHP_EOL);
fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: " . ($rows[$lead1->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: " . ($rows[$lead2->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: " . ($rows[$lead3->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: " . ($rows[$lead4->id]->resolved_subject_code ?? 'null') . PHP_EOL);
// Aggregate counts from supplier_leads.
$actualSources = array_map(fn ($id) => $rows[$id]->region_source ?? 'MISSING', $leadIds);
$counts = array_count_values($actualSources);
fwrite(STDOUT, 'Source counts: ' . json_encode($counts, JSON_UNESCAPED_UNICODE) . PHP_EOL);
fwrite(STDOUT, '=== END X3 ===' . PHP_EOL . PHP_EOL);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Lead 1: expect 'dadata'.
expect($source1)->toBe('dadata',
"FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. " .
"Got: '{$source1}'. " .
"If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). " .
"If 'unknown': DaData is disabled or threw despite stub."
);
// Lead 2: expect 'rossvyaz'.
expect($source2)->toBe('rossvyaz',
"FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. " .
"Got: '{$source2}'. " .
"If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). " .
"If 'dadata': FakeDaDataPhoneClient stubThrows didn't fire (stub registration issue)."
);
// Lead 3: expect 'tag'.
expect($source3)->toBe('tag',
"FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. " .
"Got: '{$source3}'. " .
"KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through " .
"Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() " .
"is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). " .
"'Москва' should map to code 82 via RegionTagResolver. " .
"If 'unknown': tag 'Москва' did not map to a region code in RegionTagResolver."
);
// Lead 4: expect 'unknown'.
expect($source4)->toBe('unknown',
"FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. " .
"Got: '{$source4}'. " .
"If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. " .
"If 'tag': empty/null tag somehow resolved to a code (check RegionTagResolver null-tag handling)."
);
// Aggregate assertion: 4 leads → exactly these 4 sources.
$expectedCounts = ['dadata' => 1, 'rossvyaz' => 1, 'tag' => 1, 'unknown' => 1];
expect($counts)->toEqual($expectedCounts,
'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. ' .
'Got: ' . json_encode($counts) . '. See individual source assertions above for details.'
);
})->group('imitation');
-222
View File
@@ -1,222 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ImitationClientsSeeder;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Schema bootstrap for the liderra_testing DB (idempotent, runs per-test).
*
* ─── WHY THIS EXISTS ────────────────────────────────────────────────────────
* `php artisan migrate:fresh` wraps each migration in a Laravel DB transaction.
* The initial migration calls `Artisan::call('partitions:create-months')` which
* opens a NEW pgsql_supplier connection. A new connection cannot see the
* uncommitted changes of the initial migration's pgsql connection in PostgreSQL
* READ COMMITTED isolation. Result: "table does not exist" when creating partitions.
*
* In addition, MonthlyPartitionManager::PARTITIONED_TABLES includes tables
* (project_routing_snapshots, lead_region_resolution_log) that are NOT in
* schema.sql but in later delta migrations so partitions:create-months also
* fails because those parent tables don't exist yet.
*
* ─── FIX ─────────────────────────────────────────────────────────────────────
* In beforeEach() (after SharesSupplierPdo has made pgsql_supplier share the
* same PDO as pgsql), we:
* 1. Check if the schema is already loaded (fast no-op if so).
* 2. If not: load schema.sql, then manually create the two delta-migration
* tables that partitions:create-months needs.
* 3. Then call Artisan::call('partitions:create-months') with shared PDO,
* pgsql_supplier sees our in-transaction DDL.
* 4. Mark remaining delta migrations as "ran" so migrate won't re-run them.
*
* Since DatabaseTransactions rolls back at test end, this is ephemeral.
* If liderra_testing was externally migrated the schema already exists
* we skip and proceed directly.
*/
beforeEach(function (): void {
// Fast path: if tenants table exists the DB is already set up — skip setup.
$hasTenants = DB::selectOne(
"SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'tenants'"
);
if ($hasTenants === null) {
$repoRoot = dirname(base_path());
// Step 1: Load the base schema.sql (creates most tables, triggers, RLS, etc.)
$schemaPath = $repoRoot . DIRECTORY_SEPARATOR . 'db' . DIRECTORY_SEPARATOR . 'schema.sql';
if (! is_readable($schemaPath)) {
throw new RuntimeException("schema.sql not found: {$schemaPath}");
}
DB::unprepared((string) file_get_contents($schemaPath));
// Step 2: Fix the webhook_dedup_keys FK that PDO sometimes swallows.
try {
DB::statement(<<<'SQL'
ALTER TABLE webhook_dedup_keys
ADD FOREIGN KEY (deal_id, deal_received_at)
REFERENCES deals (id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED
SQL);
} catch (Throwable) { /* already exists — idempotent */ }
// Step 3: Create tables that are in PARTITIONED_TABLES but NOT in schema.sql
// (they were added via delta migrations). partitions:create-months needs them.
// 3a. project_routing_snapshots (delta migration 2026_05_27_120000)
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS project_routing_snapshots (
snapshot_date DATE NOT NULL,
project_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
regions INT[] NOT NULL DEFAULT '{}',
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
signal_identifier TEXT,
sms_senders JSONB,
sms_keyword TEXT,
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (snapshot_date, project_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) PARTITION BY RANGE (snapshot_date)
SQL);
try {
DB::statement(
'CREATE INDEX project_routing_snapshots_tenant_date_idx
ON project_routing_snapshots (tenant_id, snapshot_date)'
);
} catch (Throwable) { /* idempotent */ }
try {
DB::statement('ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY');
DB::statement(
"CREATE POLICY project_routing_snapshots_tenant_isolation
ON project_routing_snapshots
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)"
);
} catch (Throwable) { /* idempotent */ }
// 3b. phone_ranges_imports + phone_ranges + lead_region_resolution_log
// (delta migration 2026_05_31_100000)
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS 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
)
SQL);
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS 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)
)
SQL);
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS 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,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
error TEXT,
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at)
SQL);
try {
DB::statement('ALTER TABLE lead_region_resolution_log ENABLE ROW LEVEL SECURITY');
} catch (Throwable) { /* idempotent */ }
// Step 4: Create month partitions — with shared PDO, pgsql_supplier sees
// all the tables we just created within the same transaction.
Artisan::call('partitions:create-months', ['--ahead' => 2]);
// Step 5: Mark the delta migrations as "ran" so they don't re-run if
// someone calls Artisan::call('migrate') later in the test suite.
$deltaRan = [
'2026_05_27_120000_create_project_routing_snapshots_table',
'2026_05_31_100000_create_phone_ranges_and_resolution_log',
];
foreach ($deltaRan as $migration) {
try {
DB::table('migrations')->updateOrInsert(
['migration' => $migration],
['batch' => 1],
);
} catch (Throwable) { /* ok if migrations table doesn't exist */ }
}
}
// Seed pricing tiers (required by LedgerService::chargeForDelivery).
(new PricingTierSeeder())->run();
// Allow cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/**
* Task 4 ImitationClientsSeeder: single-project matrix (36 rows).
*
* Matrix axes:
* signal {site, call} 2
* regions {[], [82], [82,83]} 3 (empty=all-RF, [82]=Moscow, [82,83]=Moscow+SPb)
* days {127 (7 days), 31 (Mon-Fri)} 2
* limit {3, 30, 300} 3
* Total: 2 × 3 × 2 × 3 = 36
*
* All project names are prefixed `IMIT-single-`.
*/
it('seeds the single-project matrix', function (): void {
(new ImitationClientsSeeder())->run();
expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36);
})->group('imitation');
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
// Set global tenant context (bypass RLS for seeding/reading)
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/**
* Task 3 Step 1 SnapshotForge::rebuild() creates a snapshot row
* for the active date for an eligible project.
*/
it('rebuild() creates a project_routing_snapshots row for the active date', function (): void {
// Arrange: tenant with positive balance (required by snapshot:rebuild eligibility)
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'frozen_by_balance_at' => null,
]);
// Arrange: active project with call signal (required by snapshot:rebuild
// INSERT: signal_type NOT NULL CHECK IN ('call','site','sms'))
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79161234567',
'daily_limit_target' => 10,
'delivery_days_mask' => 127, // all 7 days
'preflight_blocked_at' => null,
]);
// Act: rebuild snapshots for the active date
SnapshotForge::rebuild();
// Assert: a snapshot row exists for the active date and this project.
// Use pgsql_supplier (BYPASSRLS) so we can see the row regardless of
// the RLS tenant context set above — mirrors how LeadRouter queries.
$activeDate = SnapshotForge::activeDate();
$row = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->where('project_id', $project->id)
->first();
expect($row)->not->toBeNull('SnapshotForge::rebuild() should insert a row for the active date');
expect((int) $row->project_id)->toBe($project->id);
expect((string) $row->snapshot_date)->toStartWith($activeDate);
})->group('imitation');
@@ -1,862 +0,0 @@
<?php
declare(strict_types=1);
/**
* TopologyMoneyIntakeTest Task 13, Phase 1 Portal Client Imitation.
*
* VERIFICATION tests against existing prod code.
* Proves topologies G1/G2/G4, money correctness, and intake validation.
* NOT TDD no prod code is modified. Differences vs plan FINDINGS.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 13
* Spec: §6.3 (topologies) + §7 Этап 0 (intake) + §7 Этап 4 (money)
*
* Money correctness verified facts (from LedgerService.php):
* - Tier price by delivered_in_month + 1 (PricingTierResolver uses count+1).
* - lead_charges.charge_source = 'rub'.
* - balance_transactions.amount_rub = '-<amountRub>' (negative string).
* - balance_rub decremented by bcdiv(priceKopecks, 100, 2).
* - supplier_lead_costs inserted when supplier resolved (B1/B2/B3/DIRECT).
* - delivered_in_month incremented on tenant after charge.
*
* Intake validation verified from SupplierWebhookController.php:
* - Bad secret 404 (hash_equals fail).
* - Rate-limit: 600/min per-IP; 601st request 429.
* - time outside ±24h validation fail 422 (Laravel validation).
* - phone not matching ^7\d{10}$ 422.
* - IP allowlist: empty list on non-production env fail-open (no 404 from IP).
*
* Worktree APP_KEY workaround: .env has 'base64:testingkeyplaceholderxxxxxxxxxxxxxxxo='
* which decodes to 31 bytes invalid for AES-256-CBC (needs 32 bytes).
* We inject a valid 32-byte key before HTTP-layer tests (G6-style, see ScenarioG5G6).
*
* Region codes: ordinal 1..89 (constitutional order), NOT ГИБДД.
* Москва = 82, Санкт-Петербург = 83.
* Verified via App\Support\RussianRegions::CODE_TO_NAME.
*/
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\ImitationClientsSeeder;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class)->group('imitation');
// ---------------------------------------------------------------------------
// Values (using define() with a guard to allow multi-process safe re-use)
// ---------------------------------------------------------------------------
/** Москва ordinal subject code (App\Support\RussianRegions::CODE_TO_NAME[82]). */
defined('TOPO_MOSCOW') || define('TOPO_MOSCOW', 82);
/** Санкт-Петербург ordinal subject code (App\Support\RussianRegions::CODE_TO_NAME[83]). */
defined('TOPO_SPB') || define('TOPO_SPB', 83);
/** Webhook secret for intake tests — 33 chars, passes strlen>=32 guard. */
defined('TOPO_SECRET') || define('TOPO_SECRET', 'intake-test-secret-32chars-aaaaab');
// ---------------------------------------------------------------------------
// Shared beforeEach
// ---------------------------------------------------------------------------
beforeEach(function (): void {
// Seed pricing tiers (required by LedgerService::chargeForDelivery).
$this->seed(PricingTierSeeder::class);
// Global bypass for cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Disable DaData by default; individual tests enable as needed.
config([
'services.dadata.enabled' => false,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
});
// ---------------------------------------------------------------------------
// Helpers (file-scoped)
// ---------------------------------------------------------------------------
/**
* Fix the worktree APP_KEY to a valid 32-byte AES-256-CBC key.
* Required before any HTTP-layer test (SupplierWebhookController).
* Named with tmi_ prefix to avoid collision with helpers in other imitation test files.
*/
function tmi_fixAppKey(): void
{
if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) {
config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]);
try {
app('encrypter')->__construct(
str_repeat('a', 32),
config('app.cipher', 'AES-256-CBC')
);
} catch (\Throwable) {
// Encrypter may already be initialized; ignore re-init errors.
}
}
}
/**
* Set a valid webhook secret in system_settings (for HTTP intake tests).
*/
function tmi_setIntakeSecret(string $secret = TOPO_SECRET): void
{
SystemSetting::query()
->where('key', 'supplier_webhook_secret')
->update(['value' => $secret]);
}
/**
* Ensure IP allowlist is empty fail-open in non-production env.
*/
function tmi_clearIpAllowlist(): void
{
SystemSetting::query()
->where('key', 'supplier_ip_allowlist')
->update(['value' => '[]']);
}
// ===========================================================================
// ─── TOPOLOGIES (§6.3) ────────────────────────────────────────────────────
// ===========================================================================
/**
* G1: One client with one Project linked to TWO different SupplierProjects.
*
* A lead arriving via supplier B1 should reach the project if it is linked to B1.
* A lead arriving via supplier B2 should ALSO reach the same project if it is linked to B2.
*
* Proves: one project can receive leads from multiple supplier sources.
*/
it('G1: project linked to two supplier sources receives leads from each', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder();
$g1 = $seeder->seedG1('IMIT-G1-topo');
/** @var Tenant $tenant */
$tenant = $g1['tenant'];
/** @var Project $project */
$project = $g1['project'];
/** @var list<SupplierProject> $suppliers */
$suppliers = $g1['suppliers'];
[$supplierB1, $supplierB2] = $suppliers;
// Set ample balance.
DB::table('tenants')->where('id', $tenant->id)->update([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
// Set project active, all-RF regions (empty = all), all days.
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'regions' => '{}',
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
$activeDate = SnapshotForge::activeDate();
// Build snapshot for the project. For B1 (non-DIRECT), routing uses the pivot
// (project_supplier_links), not signal_identifier in snapshot. The snapshot just
// needs to exist with the correct project_id and date.
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier ?? 'g1-proj.test',
dailyLimit: 50,
regions: '{}',
);
// Lead via B1 source. Inject using the supplier's unique_key so resolveOrStub
// finds the SAME supplier_project that the pivot links to.
$phoneB1 = '79161111001';
$fake = new FakeDaDataPhoneClient();
$fake->stub($phoneB1, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector();
$leadB1 = $injector->site(
domain: $supplierB1->unique_key ?? 'g1-b1.test',
phone: $phoneB1,
tag: 'Москва',
platform: $supplierB1->platform,
vid: 9_100_000_001,
);
$dealsB1 = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->count();
expect($dealsB1)->toBeGreaterThan(0,
'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. ' .
"supplier_project_id={$supplierB1->id}, project_id={$project->id}"
);
})->group('imitation');
/**
* G2: Two clients (Tenants/Projects) linked to the SAME SupplierProject.
*
* A lead on that supplier should be eligible for both projects (weighted lottery selects
* ≥1 recipient). Each client's project must receive at least 1 lead across N injections.
*/
it('G2: two clients on same supplier each receive at least one lead', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder();
$g2 = $seeder->seedG2(
['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]],
['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]],
);
/** @var SupplierProject $supplier */
$supplier = $g2['supplier'];
/** @var list<Project> $projects */
$projects = $g2['projects'];
/** @var list<Tenant> $tenants */
$tenants = $g2['tenants'];
$activeDate = SnapshotForge::activeDate();
foreach ([$projects[0], $projects[1]] as $idx => $project) {
DB::table('tenants')->where('id', $tenants[$idx]->id)->update([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'regions' => '{' . TOPO_MOSCOW . '}',
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier,
dailyLimit: 20,
regions: '{' . TOPO_MOSCOW . '}',
);
}
// Inject 10 leads — with two projects at equal weight (20) and a cap>1
// both should receive at least 1 deal across 10 leads.
// Use the supplier's unique_key as the domain — LeadInjector builds "B2_{unique_key}"
// and RouteSupplierLeadJob::resolveOrStub() looks up supplier_projects by (platform, unique_key).
$supplierDomain = $supplier->unique_key ?? 'g2-src.test';
$fake = new FakeDaDataPhoneClient();
for ($i = 1; $i <= 10; $i++) {
$phone = '79162' . str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$fake->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
}
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector();
for ($i = 1; $i <= 10; $i++) {
$phone = '79162' . str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$injector->site(
domain: $supplierDomain,
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_200_000_000 + $i,
);
}
$deals0 = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenants[0]->id)
->count();
$deals1 = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenants[1]->id)
->count();
$totalDeals = $deals0 + $deals1;
expect($totalDeals)->toBeGreaterThan(0,
'FINDING: G2 — no deals created for either client.'
);
// Each client should receive at least 1 deal across 10 leads with equal weight.
// With cap=3 per lead (LeadRouter selects up to 3) and 2 equally-weighted projects,
// both should receive deals. This is probabilistic but seed-based.
// If one is 0, it indicates routing bias — report as FINDING.
expect($deals0)->toBeGreaterThan(0,
"FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. " .
"Both clients have equal weight. Possible routing bias."
);
expect($deals1)->toBeGreaterThan(0,
"FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. " .
"Both clients have equal weight. Possible routing bias."
);
})->group('imitation');
/**
* G4: One client with TWO Projects on the SAME SupplierProject, each targeting a different region.
*
* Lead with subject_code=82 (Москва) must go to projectA (regions=[82]).
* Lead with subject_code=83 (СПб) must go to projectB (regions=[83]).
*/
it('G4: two projects on same supplier with different regions each receive region-matching leads', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder();
$g4 = $seeder->seedG4(TOPO_MOSCOW, TOPO_SPB);
/** @var SupplierProject $supplier */
$supplier = $g4['supplier'];
/** @var Tenant $tenant */
$tenant = $g4['tenant'];
/** @var Project $projectA */
$projectA = $g4['projectA']; // regions=[82] Москва
/** @var Project $projectB */
$projectB = $g4['projectB']; // regions=[83] СПб
DB::table('tenants')->where('id', $tenant->id)->update([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
foreach ([$projectA, $projectB] as $project) {
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
}
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: $projectA->signal_identifier,
dailyLimit: 50,
regions: '{' . TOPO_MOSCOW . '}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: $projectB->signal_identifier,
dailyLimit: 50,
regions: '{' . TOPO_SPB . '}',
);
// Use the supplier's unique_key as the domain — resolveOrStub looks up by (platform, unique_key).
$supplierKey = $supplier->unique_key ?? 'g4-src.test';
$injector = new LeadInjector();
// Lead A: Москва phone → resolved to code 82 → should go to projectA only.
$phoneMoscow = '79163000001';
$fakeMoscow = (new FakeDaDataPhoneClient())->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeMoscow);
$injector->site(
domain: $supplierKey,
phone: $phoneMoscow,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_400_000_001,
);
$dealsAfterMoscowLead_A = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectA->id)
->count();
$dealsAfterMoscowLead_B = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectB->id)
->count();
expect($dealsAfterMoscowLead_A)->toBeGreaterThan(0,
'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). ' .
"projectA_id={$projectA->id}, projectB_id={$projectB->id}"
);
expect($dealsAfterMoscowLead_B)->toBe(0,
"FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). " .
"Expected 0 deals for projectB, got {$dealsAfterMoscowLead_B}."
);
// Lead B: СПб phone → resolved to code 83 → should go to projectB only.
$phoneSpb = '79163000002';
$fakeSpb = (new FakeDaDataPhoneClient())->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fakeSpb);
$injector->site(
domain: $supplierKey,
phone: $phoneSpb,
tag: 'Санкт-Петербург',
platform: $supplier->platform,
vid: 9_400_000_002,
);
$dealsAfterSpbLead_A = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectA->id)
->count();
$dealsAfterSpbLead_B = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->where('project_id', $projectB->id)
->count();
// projectA should still have only the first lead (unchanged).
expect($dealsAfterSpbLead_A)->toBe($dealsAfterMoscowLead_A,
"FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). " .
"Expected {$dealsAfterMoscowLead_A} deals for projectA, got {$dealsAfterSpbLead_A}."
);
expect($dealsAfterSpbLead_B)->toBeGreaterThan(0,
'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). ' .
"projectA_id={$projectA->id}, projectB_id={$projectB->id}"
);
})->group('imitation');
// ===========================================================================
// ─── MONEY CORRECTNESS (§7 Этап 4) ─────────────────────────────────────────
// ===========================================================================
/**
* After a successful delivery:
* 1. lead_charges row exists with correct tier price and charge_source='rub'.
* 2. balance_transactions row has negative amount_rub matching the price.
* 3. balance_transactions.balance_rub_after = balance_before price (bcmath, no kopeck loss).
* 4. supplier_lead_costs row exists.
* 5. tenants.balance_rub decreased by exactly the tier price.
* 6. tenants.delivered_in_month incremented.
*
* Tier lookup: delivered_in_month starts at 0; resolver uses count+1=1 tier_no=1.
* Tier 1: leads_in_tier=100, price_per_lead_kopecks=50000 500.00 rub.
*/
it('money: lead_charges, balance_transactions, supplier_lead_costs are correct after delivery', function (): void {
config(['services.dadata.enabled' => true]);
// Create a fresh tenant with known starting balance.
// Tier 1 price = 50000 kopecks = 500.00 rub (delivered_in_month=0 → count+1=1).
$initialBalance = '1000.00';
$expectedPriceKopecks = 50000; // tier 1
$expectedAmountRub = '500.00'; // 50000 / 100
$tenant = Tenant::factory()->create([
'balance_rub' => $initialBalance,
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
]);
$user = \App\Models\User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
]);
$phone = '79164000001';
// PostgresIntArray cast requires PHP array, not '{...}' string literal.
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test',
'regions' => [], // empty = all-RF; cast converts to '{}'
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_in_month' => 0,
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier,
dailyLimit: 50,
regions: '{}',
);
$fakeDaData = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
$injector = new LeadInjector();
$injector->site(
domain: ltrim($project->signal_identifier, '/'),
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_500_000_001,
);
// ── Reload tenant to see updated balance ────────────────────────────────
$tenantAfter = $tenant->fresh();
// ── Assert deal was created ─────────────────────────────────────────────
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->latest('id')
->first();
expect($deal)->not->toBeNull('FINDING: No deal was created for the test lead.');
// ── 1. lead_charges ─────────────────────────────────────────────────────
$charge = DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('deal_id', $deal->id)
->first();
expect($charge)->not->toBeNull('FINDING: lead_charges row missing after delivery.');
expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks,
"FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, " .
"expected {$expectedPriceKopecks} (tier 1, delivered_in_month was 0 → count+1=1)."
);
expect($charge->charge_source)->toBe('rub',
"FINDING: lead_charges.charge_source = '{$charge->charge_source}', expected 'rub'."
);
expect((int) $charge->tier_no)->toBe(1,
"FINDING: lead_charges.tier_no = {$charge->tier_no}, expected 1 (delivered_in_month+1=1 → tier 1)."
);
// ── 2. balance_transactions ─────────────────────────────────────────────
$bt = DB::table('balance_transactions')
->where('tenant_id', $tenant->id)
->orderBy('id', 'desc')
->first();
expect($bt)->not->toBeNull('FINDING: balance_transactions row missing after delivery.');
// amount_rub must be negative (stored as '-500.00').
$amountRub = (string) $bt->amount_rub;
expect(bccomp($amountRub, '0', 2))->toBe(-1,
"FINDING: balance_transactions.amount_rub = '{$amountRub}' is not negative."
);
// The absolute value must equal the tier price.
$absAmount = ltrim($amountRub, '-');
expect($absAmount)->toBe($expectedAmountRub,
"FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', " .
"expected '{$expectedAmountRub}' (kopecks={$expectedPriceKopecks} → rub=500.00)."
);
// ── 3. No kopeck loss (bcmath precision check) ───────────────────────────
// Expected new balance = 1000.00 - 500.00 = 500.00
$expectedNewBalance = bcsub($initialBalance, $expectedAmountRub, 2);
$actualNewBalance = (string) $tenantAfter->balance_rub;
// Normalize: bcmath may return '500.00'; DB may store '500.00' as well.
expect($actualNewBalance)->toBe($expectedNewBalance,
"FINDING: KOPECK LOSS detected. " .
"Initial balance: {$initialBalance}, price: {$expectedAmountRub}. " .
"Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. " .
"This indicates floating-point or bcmath precision error."
);
// balance_rub_after in balance_transactions must match actual tenant balance.
$btBalanceAfter = (string) $bt->balance_rub_after;
expect($btBalanceAfter)->toBe($expectedNewBalance,
"FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', " .
"expected '{$expectedNewBalance}'. Ledger audit trail inconsistency."
);
// ── 4. supplier_lead_costs ───────────────────────────────────────────────
$slc = DB::table('supplier_lead_costs')
->where('deal_id', $deal->id)
->first();
expect($slc)->not->toBeNull(
'FINDING: supplier_lead_costs row missing after delivery. ' .
'LedgerService should insert it when supplier resolved via platform B2.'
);
// ── 5. delivered_in_month incremented ───────────────────────────────────
expect((int) $tenantAfter->delivered_in_month)->toBe(1,
"FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, " .
"expected 1 after first lead delivery (started at 0)."
);
})->group('imitation');
/**
* Tier price uses delivered_in_month + 1 at the moment of charge.
*
* Tenant with delivered_in_month=99 count+1=100 still tier 1 (leads_in_tier=100).
* Tenant with delivered_in_month=100 count+1=101 tier 2 (price=45000 kopecks).
*/
it('money: tier is resolved by delivered_in_month+1 boundary', function (): void {
config(['services.dadata.enabled' => true]);
// delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks = 450.00 rub).
$expectedPriceKopecks = 45000;
$expectedAmountRub = '450.00';
$tenant = Tenant::factory()->create([
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1)
]);
\App\Models\User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$supplierKey2 = $supplier->unique_key; // used for injection domain
// Give the project the supplier's unique_key as signal_identifier so the factory
// asSiteSignal sets it correctly; alternatively pass signal_type/signal_identifier directly.
$project = Project::factory()
->asSiteSignal($supplierKey2)
->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_in_month' => 100,
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $supplierKey2,
dailyLimit: 50,
regions: '{}',
);
$phone = '79165000002';
$fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector();
$injector->site(
domain: $supplierKey2,
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_500_100_001,
);
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->latest('id')
->first();
expect($deal)->not->toBeNull('FINDING: No deal created for tier boundary test.');
$charge = DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('deal_id', $deal->id)
->first();
expect($charge)->not->toBeNull('FINDING: lead_charges row missing for tier boundary test.');
expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks,
"FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 " .
"(price=45000 kopecks). Got: {$charge->price_per_lead_kopecks}."
);
expect($charge->charge_source)->toBe('rub');
})->group('imitation');
// ===========================================================================
// ─── INTAKE VALIDATION (§7 Этап 0) ─────────────────────────────────────────
// ===========================================================================
/**
* Intake: bad secret 404.
*
* SupplierWebhookController: verifySecret() uses hash_equals; wrong secret 404.
*/
it('intake: bad secret returns 404', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/THIS-IS-THE-WRONG-SECRET-XXXXX', [
'vid' => 999_001,
'project' => 'B2_some-domain.test',
'phone' => '79161234567',
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(404,
'FINDING: Bad webhook secret did not return 404. ' .
"Got HTTP {$response->status()}."
);
})->group('imitation');
/**
* Intake: phone not matching ^7\d{10}$ 422.
*
* Controller validate: 'phone' => ['required', 'string', 'regex:/^7\d{10}$/'].
* An 11-digit number starting with 8 fails the regex Laravel returns 422.
*/
it('intake: invalid phone (wrong prefix) returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_002,
'project' => 'B2_some-domain.test',
'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Phone starting with 8 (invalid) did not return 422. ' .
"Got HTTP {$response->status()}. Response: " . $response->content()
);
})->group('imitation');
/**
* Intake: phone too short 422.
*/
it('intake: too-short phone returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_003,
'project' => 'B2_some-domain.test',
'phone' => '7916123', // too short — only 7 digits after 7
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Short phone did not return 422. ' .
"Got HTTP {$response->status()}."
);
})->group('imitation');
/**
* Intake: time outside ±24h 422.
*
* Controller: min = now()-24h, max = now()+24h. timestamp 48h in past fails validation.
*/
it('intake: timestamp 48h in the past (beyond -24h window) returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$oldTime = now()->subHours(48)->getTimestamp(); // 48h ago — outside ±24h window
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_004,
'project' => 'B2_some-domain.test',
'phone' => '79161234568',
'time' => $oldTime,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Timestamp 48h in the past did not return 422. ' .
"Got HTTP {$response->status()}. " .
"Controller requires time within ±24h (min=now-24h, max=now+24h)."
);
})->group('imitation');
/**
* Intake: time outside +24h (future) 422.
*/
it('intake: timestamp 48h in the future (beyond +24h window) returns 422', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$futureTime = now()->addHours(48)->getTimestamp(); // 48h in future
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_005,
'project' => 'B2_some-domain.test',
'phone' => '79161234569',
'time' => $futureTime,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Timestamp 48h in the future did not return 422. ' .
"Got HTTP {$response->status()}."
);
})->group('imitation');
/**
* Intake: flood >600/min from one IP 429.
*
* Controller uses RateLimiter::tooManyAttempts($key, 600) per-IP.
* After 600 hits the 601st attempt should be rate-limited.
*
* Note: We manipulate the rate limiter counter directly via RateLimiter::hit()
* to avoid actually making 601 HTTP requests (too slow for a test).
* This verifies the rate-limit enforcement path, not the counter increment.
*/
it('intake: rate-limit 600/min per IP — 601st request returns 429', function (): void {
tmi_fixAppKey();
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
// Simulate the rate limiter already being at its limit for '127.0.0.1'.
// The controller uses key 'supplier-webhook:<ip>'.
$rateKey = 'supplier-webhook:127.0.0.1';
// Clear any existing state first, then saturate the limiter.
RateLimiter::clear($rateKey);
// Hit 600 times to reach the limit (the 601st should be too-many).
for ($i = 0; $i < 600; $i++) {
RateLimiter::hit($rateKey, 60);
}
// Now the 601st HTTP request should see tooManyAttempts = true.
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_006,
'project' => 'B2_some-domain.test',
'phone' => '79161234570',
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(429,
'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). ' .
"Got HTTP {$response->status()}. " .
"Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken."
);
// Clean up rate limiter state to avoid cross-test pollution.
RateLimiter::clear($rateKey);
})->group('imitation');
@@ -79,6 +79,13 @@ test('ensureMonth создаёт партицию tenant_operations_log (created
expect(partitionExists('tenant_operations_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию webhook_log (received_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('webhook_log', Carbon::parse('2024-03-01'));
expect(partitionExists('webhook_log_y2024_m03'))->toBeTrue();
});
test('ensureMonth создаёт партицию balance_transactions (created_at)', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureMonth('balance_transactions', Carbon::parse('2024-03-01'));
@@ -138,25 +145,3 @@ test('ensureMonth создаёт партицию project_routing_snapshots (sna
expect(partitionExists('project_routing_snapshots_y2024_m07'))->toBeTrue();
});
// ---------------------------------------------------------------------------
// migrate:fresh resilience: ensureMonth must SKIP (not throw) a partitioned
// table whose parent does not exist yet. During migrate:fresh the initial
// schema-load migration runs partitions:create-months before later delta
// migrations create their own partitioned tables (project_routing_snapshots,
// lead_region_resolution_log). Those migrations create their own partitions;
// the manager must skip the not-yet-existing parent rather than crash the run.
// ---------------------------------------------------------------------------
test('ensureMonth пропускает таблицу без существующего родителя (migrate:fresh resilience)', function (): void {
$manager = app(MonthlyPartitionManager::class);
// Drop a known partitioned parent within the test transaction (rolled back at end),
// reproducing the "parent not created yet" ordering case.
DB::connection(MonthlyPartitionManager::DDL_CONNECTION)
->statement('DROP TABLE IF EXISTS lead_region_resolution_log CASCADE');
$result = $manager->ensureMonth('lead_region_resolution_log', Carbon::parse('2024-03-01'));
// Guard returns false (skipped) instead of throwing "relation does not exist".
expect($result)->toBeFalse();
});
@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Imitation;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* Рычаги условий для имитационного стенда (Phase 1).
*
* Напрямую пишет в реальные колонки, которые читает прод-код:
* - tenants.balance_rub LedgerService (bcmath balance_rub*100 >= price)
* - tenants.frozen_by_balance_at PreflightBalanceService (NULL = активен)
* - projects.is_active SnapshotRebuildCommand eligibility
* - projects.delivered_today LeadRouter остаток лимита
* - projects.delivery_days_mask LeadRouter / SnapshotRebuildCommand
* - projects.regions LeadRouter regional cascade
* - projects.preflight_blocked_at SnapshotRebuildCommand eligibility
*
* Имена колонок подтверждены чтением db/schema.sql и прод-кода:
* - balance_rub: tenants, DECIMAL(12,2) DEFAULT 0
* - frozen_by_balance_at: tenants, TIMESTAMPTZ NULL (NULL = не заморожен)
* - regions: projects, INT[] NOT NULL DEFAULT '{}' (порядковые коды 1..89, НЕ ГИБДД)
* - delivery_days_mask: projects, INT NOT NULL DEFAULT 127 (bit 0=Пн..bit 6=Вс)
* - daily_limit_target: projects, INT NOT NULL DEFAULT 10
* - delivered_today: projects, INT (остаток лимита)
* - preflight_blocked_at: projects, TIMESTAMPTZ NULL
*
* Коды субъектов ПОРЯДКОВЫЕ 1..89 (конституционный порядок), НЕ коды ГИБДД.
* Использовать только через App\Support\RussianRegions::CODE_TO_NAME / nameToCode().
* Например: Москва = 82, Санкт-Петербург = 83.
*
* Task 3 Phase 1 Portal Client Imitation Harness.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
final class ConditionLevers
{
/**
* Установить баланс тенанта (в рублях, как DECIMAL(12,2)).
*
* @param int|float|string $rub Сумма в рублях (например 500.00 или 0).
*/
public static function setBalance(Tenant $tenant, int|float|string $rub): void
{
DB::table('tenants')
->where('id', $tenant->id)
->update(['balance_rub' => $rub]);
}
/**
* Обнулить баланс тенанта до 0 (лид не пройдёт LedgerService::chargeForDelivery).
*/
public static function drainBalance(Tenant $tenant): void
{
self::setBalance($tenant, 0);
}
/**
* Выставить delivered_today = daily_limit_target у проекта,
* чтобы LeadRouter не считал его eligible (лимит исчерпан).
*
* LeadRouter: `projects.delivered_today < snap.daily_limit` равенство = не eligible.
*/
public static function fillToLimit(Project $project): void
{
$limit = (int) DB::table('projects')
->where('id', $project->id)
->value('daily_limit_target');
DB::table('projects')
->where('id', $project->id)
->update(['delivered_today' => $limit]);
}
/**
* Приостановить проект: is_active = false + paused_at = NOW().
*
* SnapshotRebuildCommand исключает проекты с is_active = false из нового snapshot.
* (Проверено: команда WHERE p.is_active = true.)
*/
public static function pause(Project $project): void
{
DB::table('projects')
->where('id', $project->id)
->update([
'is_active' => false,
'paused_at' => now(),
]);
}
/**
* Заморозить тенанта по балансу: frozen_by_balance_at = NOW().
*
* LeadRouter: WHERE tenants.frozen_by_balance_at IS NULL заморожен = не eligible.
* SnapshotRebuildCommand: WHERE t.frozen_by_balance_at IS NULL не попадёт в snapshot.
*/
public static function freeze(Tenant $tenant): void
{
DB::table('tenants')
->where('id', $tenant->id)
->update(['frozen_by_balance_at' => now()]);
}
/**
* Установить регионы проекта (порядковые коды 1..89, НЕ коды ГИБДД).
*
* LeadRouter Фаза 1: exact ?::int = ANY(snap.regions).
* Пустой массив = «вся РФ» (LeadRouter Фаза 2, regions = '{}').
*
* Пример: [82] = только Москва, [82, 83] = Москва + СПб, [] = вся РФ.
* Коды через App\Support\RussianRegions::CODE_TO_NAME / nameToCode().
*
* @param array<int> $codes Порядковые коды субъектов (1..89).
*/
public static function setRegions(Project $project, array $codes): void
{
// PostgreSQL int[] литерал: '{82,83}' или '{}'.
$pgArray = '{' . implode(',', array_map('intval', $codes)) . '}';
DB::table('projects')
->where('id', $project->id)
->update(['regions' => $pgArray]);
}
/**
* Установить битмаску дней приёма лидов.
*
* Бит 0 (1) = Понедельник, бит 6 (64) = Воскресенье.
* 127 = все 7 дней; 31 = Пн–Пт; 96 = Сб+Вс.
* SnapshotRebuildCommand: WHERE (p.delivery_days_mask & weekdayBit) <> 0.
*
* @param int $mask Битмаска 0..127.
*/
public static function setDays(Project $project, int $mask): void
{
DB::table('projects')
->where('id', $project->id)
->update(['delivery_days_mask' => $mask]);
}
}
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Imitation;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataPhoneResponse;
/**
* Детерминированный фейк DaData-клиента для тестов имитации (Phase 1).
*
* Позволяет прогонять каскад LeadRegionResolver без внешних HTTP-вызовов:
* заранее регистрируем ответы по номеру телефона через stub(), затем
* биндим этот фейк в контейнер вместо реального DaDataPhoneClient.
*
* Использование:
* $fake = new FakeDaDataPhoneClient();
* $fake->stub('79990000077', qc: 0, region: 'Москва', provider: 'МТС');
* app()->instance(DaDataPhoneClient::class, $fake);
*
* Task 1 Phase 1 Portal Client Imitation Harness.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
class FakeDaDataPhoneClient extends DaDataPhoneClient
{
/**
* @var array<string, DaDataPhoneResponse|null> phone => response (null = throw DaDataException)
*/
private array $stubs = [];
/**
* Переопределяем конструктор без вызова parent, чтобы не требовать HttpFactory в тестах.
*/
public function __construct() {}
/**
* Зарегистрировать детерминированный ответ для номера телефона.
*
* @param int $qc Код качества DaData (0=хорошо, 1=не уточнён, 2=мусор, 3=изменён, 7=иностранец)
*/
public function stub(
string $phone,
int $qc,
?string $region = null,
?string $provider = null,
): self {
$this->stubs[$phone] = new DaDataPhoneResponse(
qc: $qc,
qcConflict: null,
type: null,
phone: $phone,
provider: $provider,
region: $region,
city: null,
timezone: null,
raw: [
'qc' => $qc,
'provider' => $provider,
'region' => $region,
'phone' => $phone,
],
);
return $this;
}
/**
* Зарегистрировать выброс DaDataException для номера телефона.
* Используется для тестирования ветки деградации (Россвязь-fallback).
*/
public function stubThrows(string $phone): self
{
$this->stubs[$phone] = null; // null = throw
return $this;
}
/**
* Возвращает заранее зарегистрированный ответ или бросает DaDataException.
*
* @throws DaDataException Если стаб не зарегистрирован или зарегистрирован как throw.
*/
public function cleanPhone(string $phone): DaDataPhoneResponse
{
if (! array_key_exists($phone, $this->stubs)) {
throw new DaDataException("FakeDaDataPhoneClient: no stub registered for phone {$phone}");
}
$response = $this->stubs[$phone];
if ($response === null) {
throw new DaDataException("FakeDaDataPhoneClient: stubbed to throw for phone {$phone}");
}
return $response;
}
}
@@ -1,281 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Imitation;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Seeder for the Phase 1 imitation harness.
*
* Creates the single-project matrix (36 projects) covering all combinations of:
* signal {site, call} 2
* regions {[], [82], [82,83]} 3 (empty=all-RF, [82]=Москва, [82,83]=Москва+СПб)
* days {127 (7 days), 31 (Mon-Fri)} 2
* limit {3, 30, 300} 3
* Total: 2 × 3 × 2 × 3 = 36
*
* All project names are prefixed IMIT-single-.
* Topology helpers (G1/G2/G4) also use IMIT- prefix.
*
* Region codes follow ordinal 1..89 (constitutional order), NOT ГИБДД codes.
* Москва = 82, Санкт-Петербург = 83 (verified via App\Support\RussianRegions::CODE_TO_NAME).
*
* Task 4 Phase 1 Portal Client Imitation Harness.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md
*/
final class ImitationClientsSeeder
{
/** Shared SupplierProject used by all matrix cells (B2 site-signal). */
private ?SupplierProject $sharedSupplier = null;
/**
* Run the seeder: creates the 36-cell single-project matrix.
* Topology helpers G1/G2/G4 (used by Task 13) are available as separate methods.
*/
public function run(): void
{
$this->sharedSupplier = $this->makeSharedSupplierProject();
$this->seedSingleProjectMatrix();
}
// -------------------------------------------------------------------------
// Matrix seeding
// -------------------------------------------------------------------------
private function seedSingleProjectMatrix(): void
{
$signals = ['site', 'call'];
// regions: empty = all-RF; [82] = Москва; [82, 83] = Москва + СПб
$regions = [[], [82], [82, 83]];
$dayMasks = [127, 31];
$limits = [3, 30, 300];
$i = 0;
foreach ($signals as $signal) {
foreach ($regions as $regionSet) {
foreach ($dayMasks as $daysMask) {
foreach ($limits as $limit) {
$i++;
$this->makeSingleProjectCell(
index: $i,
signal: $signal,
regions: $regionSet,
daysMask: $daysMask,
limit: $limit,
);
}
}
}
}
}
/**
* Create one Tenant + User + Project + pivot link for a matrix cell.
*
* @param array<int> $regions Ordinal subject codes 1..89 (empty = all-RF).
*/
private function makeSingleProjectCell(
int $index,
string $signal,
array $regions,
int $daysMask,
int $limit,
): void {
$tenant = Tenant::factory()->create();
User::factory()->create(['tenant_id' => $tenant->id]);
// Unique signal identifier to avoid UNIQUE constraint violations.
$uniqueSuffix = Str::random(6);
$signalIdentifier = $signal === 'site'
? "imit-{$index}-{$uniqueSuffix}.test"
: '7' . str_pad((string) (9000000000 + $index), 10, '0', STR_PAD_LEFT) . $uniqueSuffix;
// Pass regions as a PHP int[] — the PostgresIntArray Eloquent cast
// converts it to the PostgreSQL literal '{82,83}' or '{}' in set().
$project = $signal === 'site'
? Project::factory()
->asSiteSignal($signalIdentifier)
->create([
'name' => "IMIT-single-{$index}",
'tenant_id' => $tenant->id,
'regions' => $regions,
'delivery_days_mask' => $daysMask,
'daily_limit_target' => $limit,
])
: Project::factory()
->asCallSignal($signalIdentifier)
->create([
'name' => "IMIT-single-{$index}",
'tenant_id' => $tenant->id,
'regions' => $regions,
'delivery_days_mask' => $daysMask,
'daily_limit_target' => $limit,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $this->sharedSupplier->id,
'platform' => $this->sharedSupplier->platform,
'subject_code' => null,
]);
}
// -------------------------------------------------------------------------
// Shared supplier project factory
// -------------------------------------------------------------------------
/**
* Create a shared SupplierProject (B2, site signal) used by all matrix cells.
* B2 supports both site and call signals (no B1+sms constraint).
*/
private function makeSharedSupplierProject(): SupplierProject
{
return SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
]);
}
// -------------------------------------------------------------------------
// Topology helpers — used by Task 13 (TopologyMoneyIntakeTest)
// -------------------------------------------------------------------------
/**
* G1 topology: one client (Tenant) with one Project linked to TWO different
* SupplierProjects (B1 + B2).
*
* Validates that LeadRouter can route leads from multiple supplier sources
* to the same project when the project is linked to multiple suppliers.
*
* @return array{tenant: Tenant, project: Project, suppliers: list<SupplierProject>}
*/
public function seedG1(string $namePrefix = 'IMIT-G1'): array
{
$tenant = Tenant::factory()->create();
User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()
->asSiteSignal("g1-{$namePrefix}-" . Str::random(6) . '.test')
->create([
'name' => "{$namePrefix}-project",
'tenant_id' => $tenant->id,
]);
$supplier1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$supplier2 = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
foreach ([$supplier1, $supplier2] as $supplier) {
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
return ['tenant' => $tenant, 'project' => $project, 'suppliers' => [$supplier1, $supplier2]];
}
/**
* G2 topology: TWO clients (Tenants/Projects) linked to the SAME SupplierProject.
*
* Validates weighted lottery and fair distribution between competing clients
* sharing a single supplier source.
*
* @param array<string, mixed> $overrides1 ProjectFactory overrides for client 1.
* @param array<string, mixed> $overrides2 ProjectFactory overrides for client 2.
* @return array{supplier: SupplierProject, projects: list<Project>, tenants: list<Tenant>}
*/
public function seedG2(array $overrides1 = [], array $overrides2 = []): array
{
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$projects = [];
$tenants = [];
foreach ([$overrides1, $overrides2] as $idx => $overrides) {
$tenant = Tenant::factory()->create();
User::factory()->create(['tenant_id' => $tenant->id]);
$tenants[] = $tenant;
$project = Project::factory()
->asSiteSignal("g2-client-{$idx}-" . Str::random(6) . '.test')
->create(array_merge([
'name' => "IMIT-G2-client-{$idx}",
'tenant_id' => $tenant->id,
], $overrides));
$projects[] = $project;
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
return ['supplier' => $supplier, 'projects' => $projects, 'tenants' => $tenants];
}
/**
* G4 topology: one client with TWO Projects on the SAME SupplierProject,
* each targeting a different region.
*
* Validates that LeadRouter dispatches leads to the project whose region
* matches the lead's resolved subject code.
*
* @param int $regionA Ordinal subject code for project A (e.g. 82 = Москва).
* @param int $regionB Ordinal subject code for project B (e.g. 83 = СПб).
* @return array{supplier: SupplierProject, tenant: Tenant, projectA: Project, projectB: Project}
*/
public function seedG4(int $regionA = 82, int $regionB = 83): array
{
$tenant = Tenant::factory()->create();
User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$uniqueA = Str::random(6);
$uniqueB = Str::random(6);
$projectA = Project::factory()
->asSiteSignal("g4-region-{$regionA}-{$uniqueA}.test")
->create([
'name' => "IMIT-G4-region-{$regionA}",
'tenant_id' => $tenant->id,
'regions' => [$regionA], // PHP int[] — PostgresIntArray cast handles conversion
]);
$projectB = Project::factory()
->asSiteSignal("g4-region-{$regionB}-{$uniqueB}.test")
->create([
'name' => "IMIT-G4-region-{$regionB}",
'tenant_id' => $tenant->id,
'regions' => [$regionB], // PHP int[] — PostgresIntArray cast handles conversion
]);
foreach ([$projectA, $projectB] as $project) {
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
return [
'supplier' => $supplier,
'tenant' => $tenant,
'projectA' => $projectA,
'projectB' => $projectB,
];
}
}
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Imitation;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\TestCase;
/**
* Base test case for Phase 1 imitation harness tests.
*
* Seeds reference data (pricing_tiers, suppliers) once per test within a
* database transaction so every test starts from a clean, known state.
*
* Usage:
* Extend this class (PHPUnit-style) or include the trait equivalents
* in Pest tests. Pest tests should prefer:
*
* uses(DatabaseTransactions::class, SharesSupplierPdo::class);
* beforeEach(fn () => (new ImitationTestCase())->seedReferenceData());
*
* Schema dependencies (exact columns verified against db/schema.sql):
* pricing_tiers: id, tier_no (1..7), leads_in_tier, price_per_lead_kopecks,
* is_active, effective_from, created_at, updated_at
* suppliers: id, code, name, accepts_types (varchar[]), cost_rub,
* channel, quality_score, is_active, sort_order, created_at
*
* Note: suppliers (b1/b2/b3/direct) are seeded via the initial schema
* migration and delta-migrations they are expected to already exist in
* the test database. This case does NOT re-seed suppliers; it only verifies
* that at least one supplier row with code='b1' is present and seeds
* pricing_tiers via PricingTierSeeder.
*
* phone_ranges are NOT seeded globally. Tests that exercise the region-
* resolution cascade (Россвязь lookup) should call seedPhoneRange() directly
* for the specific range their scenario requires.
*
* Task 0.5 Phase 1 Portal Client Imitation Harness.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
abstract class ImitationTestCase extends TestCase
{
use DatabaseTransactions;
use SharesSupplierPdo;
protected function setUp(): void
{
parent::setUp();
$this->seedReferenceData();
}
/**
* Seed shared reference data required by all imitation tests.
*
* Called automatically from setUp(). Safe to call multiple times within a
* transaction (PricingTierSeeder uses updateOrCreate; supplier check is
* read-only).
*/
public function seedReferenceData(): void
{
// Pricing tiers — required by LedgerService::chargeForDelivery.
// PricingTierSeeder uses updateOrCreate so it is safe to call within
// a DatabaseTransactions-wrapped test.
$this->seed(PricingTierSeeder::class);
// Tenant context: global bypass to allow cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
}
/**
* Seed a single phone range for Россвязь prefix lookup tests.
*
* Only call this when your specific test scenario exercises the Россвязь
* branch of LeadRegionResolver (e.g. DaData degradation tests).
*
* @param string $defCode DEF-code prefix (e.g. '999').
* @param string $from Lower bound of number range (e.g. '0000000').
* @param string $to Upper bound of number range (e.g. '0099999').
* @param int $subjectCode Subject code (1..89, порядковый, НЕ ГИБДД).
* Use App\Support\RussianRegions::nameToCode() for lookup.
*/
protected function seedPhoneRange(
int $defCode,
int $from,
int $to,
int $subjectCode,
): void {
// Anchor phone_ranges_imports row first — phone_ranges.import_id is a
// required FK (migration 2026_05_31_100000). F1 fix: the previous version
// used non-existent columns (range_from/range_to/region_name) and omitted
// import_id, so every Россвязь-branch test that called it failed at runtime.
$importId = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => str_repeat('0', 64),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => \App\Support\RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized' => null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
]);
}
}
@@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Imitation;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
/**
* Инъектор синтетических заявок для имитационного стенда (Phase 1).
*
* Создаёт SupplierLead в БД и синхронно прогоняет RouteSupplierLeadJob
* без HTTP-слоя, минуя secret/IP/rate-limit SupplierWebhookController.
*
* raw_payload формируется с теми же ключами (vid/project/phone/time/tag),
* что контроллер кладёт из $request->validate(). platform парсится по тому же
* правилу: B[123]_ B1/B2/B3, иначе DIRECT.
*
* Используется исключительно в тест-инфраструктуре (Tests-namespace).
*
* Task 2 Phase 1 Portal Client Imitation Harness.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
final class LeadInjector
{
/**
* Инъектировать заявку с сигналом «сайт».
*
* @param string $domain Домен сигнала (например, 'vashinvestor.ru').
* Составляет identifier в project-поле: "{$platform}_{$domain}".
* @param string $phone Телефон в формате 7XXXXXXXXXX.
* @param string|null $tag Тег региона (например, 'Москва'); может быть null.
* @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное ( DIRECT).
* @param int|null $vid Внешний ID заявки поставщика. Если null генерируется уникальный.
*/
public function site(
string $domain,
string $phone,
?string $tag = null,
string $platform = 'B1',
?int $vid = null,
): SupplierLead {
$project = "{$platform}_{$domain}";
return $this->inject($project, $phone, $tag, $platform, $vid);
}
/**
* Инъектировать заявку с сигналом «звонок».
*
* @param string $number Номер телефона для call-сигнала (7XXXXXXXXXX).
* Составляет identifier в project-поле: "{$platform}_{$number}".
* @param string $phone Телефон звонящего в формате 7XXXXXXXXXX.
* @param string|null $tag Тег региона; может быть null.
* @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное ( DIRECT).
* @param int|null $vid Внешний ID заявки поставщика. Если null генерируется уникальный.
*/
public function call(
string $number,
string $phone,
?string $tag = null,
string $platform = 'B1',
?int $vid = null,
): SupplierLead {
$project = "{$platform}_{$number}";
return $this->inject($project, $phone, $tag, $platform, $vid);
}
/**
* Общий внутренний метод создания SupplierLead + синхронный dispatch Job.
*
* raw_payload содержит ровно те ключи, что SupplierWebhookController кладёт
* из $request->validate(): vid, project, phone, time, tag (null не добавляем,
* чтобы не нарушить nullable-контракт RouteSupplierLeadJob).
*
* platform парсится по тому же правилу, что Controller::parsePlatform():
* /^(B[123])_/ B1/B2/B3, иначе DIRECT.
*/
private function inject(
string $project,
string $phone,
?string $tag,
string $platform,
?int $vid,
): SupplierLead {
$resolvedVid = $vid ?? $this->generateVid();
$parsedPlatform = $this->parsePlatform($project);
$rawPayload = [
'vid' => $resolvedVid,
'project' => $project,
'phone' => $phone,
'time' => time(),
];
if ($tag !== null) {
$rawPayload['tag'] = $tag;
}
$lead = SupplierLead::create([
'platform' => $parsedPlatform,
'raw_payload' => $rawPayload,
'vid' => $resolvedVid,
'phone' => $phone,
'received_at' => now(),
'source' => 'webhook',
]);
RouteSupplierLeadJob::dispatchSync($lead->id);
return $lead->refresh();
}
/**
* Парсит platform из project-поля идентично Controller::parsePlatform().
* B[123]_ B1/B2/B3, иначе DIRECT.
*/
private function parsePlatform(string $project): string
{
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
return $m[1];
}
return 'DIRECT';
}
/**
* Генерирует уникальный vid для использования, когда явный vid не передан.
* Диапазон 1_000_000_000..9_999_999_999 вне реальных vid поставщика (< 10^9).
*/
private function generateVid(): int
{
return random_int(1_000_000_000, 9_999_999_999);
}
}
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Imitation;
use Carbon\Carbon;
use Illuminate\Support\Facades\Artisan;
/**
* Генератор снапшота маршрутизации для имитационного стенда (Phase 1).
*
* Делегирует команде `snapshot:rebuild` (DELETE+INSERT, детерминированно):
* - activeDate() зеркало LeadRouter::activeSnapshotDate(): до 21:00 МСК сегодня,
* с 21:00 МСК завтра (§4.2.3 slepok-routing spec).
* - rebuild() вызывает Artisan::call('snapshot:rebuild', ['--date' => activeDate()])
* для перестройки project_routing_snapshots за активную дату.
*
* Механизм выбора (per README §4):
* «Для живого портала (Task 14) `php artisan snapshot:rebuild --date=<activeDate>`
* (DELETE+INSERT, детерминированно)».
*
* В тестах `rebuild()` вызывается статически ПОСЛЕ создания проекта (фабрика),
* до вызова LeadRouter. SnapshotRebuildCommand работает через pgsql_supplier
* (BYPASSRLS crm_supplier_worker) совместимо с SharesSupplierPdo.
*
* activeDate() возвращает строку 'YYYY-MM-DD' ровно то, что LeadRouter передаёт
* в SQL WHERE snap.snapshot_date = ?::date.
*
* Task 3 Phase 1 Portal Client Imitation Harness.
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
*/
final class SnapshotForge
{
/**
* Активная дата слепка зеркало LeadRouter::activeSnapshotDate().
*
* До 21:00 МСК сегодня, с 21:00 МСК завтра (§4.2.3).
*
* Возвращает строку 'YYYY-MM-DD' (как LeadRouter передаёт в SQL).
*/
public static function activeDate(): string
{
$msk = Carbon::now('Europe/Moscow');
return $msk->hour >= 21
? $msk->copy()->addDay()->toDateString()
: $msk->toDateString();
}
/**
* Перестраивает project_routing_snapshots за активную дату из live-проектов.
*
* Использует команду `snapshot:rebuild --date=<activeDate>` (DELETE+INSERT),
* которая отбирает все active + unfrozen проекты с подходящим delivery_days_mask
* и вставляет строки в project_routing_snapshots.
*
* Вызывать ПОСЛЕ создания тестовых проектов (фабрика) команда читает
* projects из БД на момент вызова.
*/
public static function rebuild(): void
{
Artisan::call('snapshot:rebuild', [
'--date' => self::activeDate(),
]);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Support\RussianRegions;
/**
* Нормализация регионов реестра Россвязи subject_code.
* Кейсы взяты из реальных топ-50 unmapped-форматов прод-реестра (02.06.2026).
*/
it('maps cities of federal significance with the г. prefix', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
});
it('still maps a plain canonical federal-city name', function (): void {
expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
});
it('takes the last pipe segment as the subject region', function (): void {
// регион = последний сегмент после |
expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
});
it('expands the обл. abbreviation to область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
});
it('keeps already-canonical край/республика segments', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
});
it('reorders the Удмуртская Республика inverted form', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
});
it('maps the Кузбасс special form to Кемеровская область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
});
it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
})->with([
'-',
'Российская Федерация',
'Москва и Московская область', // неоднозначно — два субъекта
'г.о. Тольятти', // нет региона в строке
'г.о. город Уфа',
'',
' ',
]);
it('exposes the canonical name via canonicalRegionName', function (): void {
expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
});
it('expands the АО abbreviation to автономный округ', function (): void {
expect(RussianRegions::resolveSubjectCode('Ненецкий АО'))->toBe(86)
->and(RussianRegions::resolveSubjectCode('Чукотский АО'))->toBe(88)
->and(RussianRegions::resolveSubjectCode('г. Салехард|Ямало-Ненецкий АО'))->toBe(89);
});
it('maps Ханты-Мансийск variants to ХМАО — Югра', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Сургут|Ханты-Мансийский Автономный округ - Югра АО'))->toBe(87)
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский АО - Югра'))->toBe(87)
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский Автономный округ - Югра.'))->toBe(87);
});
it('reorders inverted Республика X forms', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Чеченская'))->toBe(23)
->and(RussianRegions::resolveSubjectCode('Республика Кабардино-Балкарская'))->toBe(8)
->and(RussianRegions::resolveSubjectCode('Республика Карачаево-Черкесская'))->toBe(10)
->and(RussianRegions::resolveSubjectCode('Республика Донецкая Народная'))->toBe(6)
->and(RussianRegions::resolveSubjectCode('Республика Луганская Народная'))->toBe(14);
});
it('keeps Республика-first canonical names as-is', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Татарстан'))->toBe(19)
->and(RussianRegions::resolveSubjectCode('Республика Карелия'))->toBe(11);
});
it('handles irregular subject spellings (Саха, Чувашия, Кузбасс)', function (): void {
expect(RussianRegions::resolveSubjectCode('у. Мирнинский|Республика Саха /Якутия/'))->toBe(17)
->and(RussianRegions::resolveSubjectCode('г. Чебоксары|Чувашская Республика - Чувашия'))->toBe(24)
->and(RussianRegions::resolveSubjectCode('Кемеровская область - Кузбасс область'))->toBe(48);
});
it('maps Moscow / SPb spelling variants', function (): void {
expect(RussianRegions::resolveSubjectCode('Город Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт - Петербург'))->toBe(83);
});
it('normalizes spaced hyphen to em-dash (Северная Осетия — Алания)', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Северная Осетия - Алания'))->toBe(18)
->and(RussianRegions::resolveSubjectCode('г. Владикавказ|Республика Северная Осетия - Алания'))->toBe(18);
});
+5
View File
@@ -0,0 +1,5 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;г. Москва
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС г. Москва
3 922 1000000 1099999 100000 ПАО Ростелеком г. Оренбург|Оренбургская обл.
4 987 5000000 5099999 100000 ПАО Ростелеком г. Ижевск|Республика Удмуртская
5 902 7000000 7009999 10000 ООО Оператор г.о. Тольятти
@@ -0,0 +1,61 @@
# Россвязь region→subject_code mapping fix — Implementation Plan
> **For agentic workers:** TDD, bite-sized steps. Steps use `- [ ]`.
**Goal:** Маппить регион из реестра Россвязи в `subject_code` через нормализацию форматов, чтобы перестать терять ~98% диапазонов (444904/453080 были NULL из-за exact-match).
**Architecture:** Чистый нормализатор в `App\Support\RussianRegions` (`canonicalRegionName` + `resolveSubjectCode`), unit-тестируемый без БД. `PhoneRangesImportCommand` зовёт его и заполняет `region_normalized`. Прод перечитывает реестр командой `phone-ranges:import` после мержа.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16.
---
## Корень проблемы (systematic-debugging Phase 1, подтверждён прод-данными)
`PhoneRangesImportCommand` делал `RussianRegions::nameToCode()[trim($rec['region'])]` — exact match. Реальные строки реестра (топ-50 unmapped, прод 02.06.2026):
- `г. Москва` (253342) / `г. Санкт-Петербург` (34573) — города фед. значения с префиксом `г. `
- `г. Оренбург|Оренбургская обл.` — регион = **последний** сегмент после `|`, область сокращена `обл.`
- `г. Воскресенск|р-н Воскресенский|Московская обл.` — 3 сегмента, регион = последний
- `г. Ижевск|Республика Удмуртская` — порядок слов перевёрнут (канон `Удмуртская Республика`)
- `г. Кемерово|Кемеровская область - Кузбасс обл.` — спец-форма
- Безнадёжные (меньшинство, остаются NULL): `-`, `Российская Федерация`, `Москва и Московская область` (неоднозначно), `г.о. Тольятти` / `г.о. город Уфа` (нет региона в строке)
## Правила нормализации
1. Взять последний сегмент после `|`, trim.
2. Прямые алиасы (приоритет): `г. Москва``Москва`, `г. Санкт-Петербург``Санкт-Петербург`, `г. Севастополь``Севастополь`, `Республика Удмуртская``Удмуртская Республика`, `Кемеровская область - Кузбасс обл.``Кемеровская область`.
3. Иначе: суффикс ` обл.`` область`.
4. Результат искать в `nameToCode()`. Нет → `null` (диапазон остаётся unmapped — корректно).
---
## Task 1: `RussianRegions::canonicalRegionName` + `resolveSubjectCode`
**Files:**
- Modify: `app/app/Support/RussianRegions.php`
- Test: `app/tests/Unit/Support/RussianRegionsTest.php`
- [ ] Step 1: написать падающий unit-тест (кейсы: фед.города с `г. `, `обл.``область`, многосегментный pipe, переворот Удмуртии, Кузбасс-алиас, безнадёжные→null, чистое каноничное имя).
- [ ] Step 2: запустить pest → RED (метод не существует).
- [ ] Step 3: реализовать `lastSegment` (private), `ALIASES` (const), `canonicalRegionName(string): ?string`, `resolveSubjectCode(string): ?int`.
- [ ] Step 4: pest → GREEN.
- [ ] Step 5: commit.
## Task 2: wire команды импорта + `region_normalized`
**Files:**
- Modify: `app/app/Console/Commands/PhoneRangesImportCommand.php:103-116`
- Modify: `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
- Modify: `app/tests/Fixtures/rossvyaz/sample.csv` (добавить грязные строки)
- [ ] Step 1: добавить в fixture строки с реальными форматами (`г. Москва`, `г. Оренбург|Оренбургская обл.`, `г. Ижевск|Республика Удмуртская`, `г.о. Тольятти`).
- [ ] Step 2: расширить command-тест: проверить, что грязные строки маппятся в правильные коды, безнадёжные → NULL, `region_normalized` заполнен.
- [ ] Step 3: pest → RED.
- [ ] Step 4: команда зовёт `RussianRegions::canonicalRegionName` + `nameToCode`, пишет `region_normalized`.
- [ ] Step 5: pest → GREEN (весь файл).
- [ ] Step 6: commit + push + PR.
## После мержа
Владелец запускает на проде через `artisan-run.yml` (mutating, confirm_apply): `phone-ranges:import --dir=<пакет> --force` — перечитывает реестр с новым маппингом. Будущие лиды резолвятся через Россвязь-fallback → меньше пустого «Город».
@@ -1,70 +0,0 @@
# HANDOFF — Имитация портала, Фаза 1 (переезд в новую сессию)
**Дата:** 03.06.2026. **Читать ПЕРВЫМ при возобновлении.**
## Где работать
- **Worktree:** `c:\моя\проекты\портал crm\Документация\.claude\worktrees\prod-imitation-clients`
- **Ветка:** `worktree-prod-imitation-clients` (база = `origin/main` с региональной фичей)
- **HEAD на момент хендоффа:** `7c5ca7f6`
- Если worktree сохранён — войти в него (`EnterWorktree` с `path`). Если удалён — пересоздать worktree от ветки `worktree-prod-imitation-clients` (commits в ней).
- Боевое/прод НЕ тронуты. Ничего не запушено.
## Читать перед работой (в этом порядке)
1. Этот файл.
2. **Спек:** `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
3. **План:** `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md` — там «Статус исполнения и поправки» вверху + 16 задач.
4. **Сигнатуры стенда:** `app/app/Support/Imitation/README.md` (выверенные интерфейсы + критические правки).
## Что уже сделано (закоммичено на ветке)
| Коммит | Что |
|---|---|
| `dee4a0e1` | спек + план |
| `bad947a5` | Task 0 — README сигнатур |
| `a54b0346` | Task 1 — `FakeDaDataPhoneClient` (Tests namespace), 7 тестов GREEN |
| `e03da647` | plan-status + **Task 2** `LeadInjector` (был GREEN, забандлен под docs-сообщением) |
| `7c5ca7f6` | Task 0.5 — `ImitationTestCase` (сидит тарифы), env-база; **+ спорные правки** (см. ниже) |
Тесты-якоря зелёные (verbatim, через `--filter`): `FakeDaDataClientTest` (3), `LeadInjectorTest` (1/6).
## ⚠️ ДВА ОТКРЫТЫХ РЕШЕНИЯ ВЛАДЕЛЬЦА (спросить до продолжения)
1. **Правки production-миграций в `7c5ca7f6`** (вне scope, сделаны субагентом):
- `0001_01_01_000000_load_initial_schema.php`: `$withinTransaction=false` + `try/catch` вокруг `partitions:create-months`;
- `2026_05_24_100000_…`, `2026_05_26_120000_…`: idempotency-гарды;
- `MonthlyPartitionManagerTest` (Feature+Unit) изменены.
Мотив правдоподобен (чинит `migrate:fresh` ordering), НО меняет поведение прод-`migrate` (нет отката; `catch(\Throwable){}` глушит ошибки). **Решить: оставить / отревьюить отдельно / откатить из ветки.** Рекомендация — вынести в отдельную ревью-задачу или откатить.
2. **Баг хука `tools/enforce-tdd-real-test-verifier`**: ложно блокирует Write `.env.testing` (regex `.(test|spec).[a-z0-9]+$` цепляет `…test`+`ng`). Из-за этого **пароль БД остался в `app/phpunit.xml`** (B4). Решить: чинить regex (тогда создать `.env.testing`, убрать пароль) / оставить.
## Жёсткие правила для субагентов (соблюдать строго)
- **Namespace стенда:** `Tests\Support\Imitation` (`app/tests/Support/Imitation/…`), НЕ `App\…` (TDD-гейт). Живая сеялка (Task 14) — отдельно в `database/seeders`.
- **Тесты — ТОЛЬКО `--filter`** (`cd app && php artisan test --filter=XxxTest`). НИКОГДА весь suite.
- **Контроллер сам тесты запускать не может** (роутер-гейт) — только субагенты.
- **Коды субъектов порядковые:** Москва=**82**, СПб=**83** (через `App\Support\RussianRegions::CODE_TO_NAME`).
- **Запрет трогать файлы вне явного списка задачи** (после scope-creep Task 2/0.5 — в каждый промпт субагента включать «менять ТОЛЬКО эти файлы: …; production-миграции и чужие тесты НЕ трогать»).
- Сценарные тесты наследуют `Tests\Support\Imitation\ImitationTestCase` (тарифы+контекст засеяны).
- Git-safety §A/§B (Pravila §15.1) на каждый Task: pre/post `rev-parse HEAD` + `branch --show-current`; commit-задачи — Sonnet/Opus.
## Что осталось (Tasks 3–15 плана)
`SnapshotForge` (3), `ConditionLevers` (3), сеялка матрицы/топологий (4), сценарии: регион-каскад (5), жребий A (6), регионы B/C (7), дни D (8), заморозки E1/E2/F (9), G3 (10), G5/G6 (11), X1/X3 подмена+журнал (12), топологии+деньги+приём (13), живая `imitation:seed` (14), runbook+отчёт (15).
## Хвосты на самый конец
- Вычистка пароля из `phpunit.xml` (и из истории ветки до пуша).
- Решение по миграциям (см. выше).
- Финальная регрессия (через субагента, `--group=imitation` + проектная регрессия).
## Готовый промт для новой сессии
> Возобнови работу над имитацией портала Фазы 1. Сначала прочитай
> `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1-HANDOFF.md`
> (он в worktree `.claude/worktrees/prod-imitation-clients`, ветка
> `worktree-prod-imitation-clients`), затем спек и план по ссылкам оттуда.
> Убедись, что работаешь в этом worktree. Задай мне два открытых вопроса
> (правки миграций; баг хука для `.env.testing`), потом продолжай Tasks 3–15
> по плану через субагентов с жёстким scope (только Tests-namespace, только
> `--filter`, не трогать миграции/чужие файлы).
@@ -1,471 +0,0 @@
# Phase 1 — Portal Client Imitation 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.
**Goal:** Построить безопасный стенд имитации работы портала глазами клиента на копии (= боевой код) и прогнать на нём все значимые ситуации, чтобы поймать логические ошибки до Фазы 2.
**Architecture:** Общие «кирпичи» (подставной DaData-клиент, инъектор заявок, генератор снапшота, рычаги условий, сеялка клиентов/проектов) используются в ДВУХ дорожках: (1) автоматический Pest-набор сценариев с жёсткими проверками поведения; (2) сеялка для наполнения живого локального портала, чтобы владелец смотрел «глазами клиента». Ничего боевого не трогаем; деньги и DaData — локальные/подставные.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16 (5 ролей + RLS) / Redis (Memurai). Зависимости из боевого пути: `RouteSupplierLeadJob`, `LeadRegionResolver`, `LeadRouter`, `LedgerService`, `SnapshotProjectRoutingJob`, `SupplierWebhookController`.
**Спек:** `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
---
## Статус исполнения и поправки (обновлено 03.06.2026)
**Сделано (закоммичено на ветке `worktree-prod-imitation-clients`):**
- Спек + план — `dee4a0e1`.
- Task 0 (сигнатуры, `app/app/Support/Imitation/README.md`) — `bad947a5`.
- Task 1 (FakeDaData) — `a54b0346`, **7 тестов GREEN**.
**Написано, НЕ закоммичено (на диске):** Task 2 `LeadInjector``app/tests/Support/Imitation/LeadInjector.php` + `app/tests/Feature/Imitation/LeadInjectorTest.php` (был GREEN в изоляции; докоммитить после стабилизации env).
**ПОПРАВКИ к этому плану (обязательны):**
1. **Namespace стенда:** все переиспользуемые «кирпичи» (`FakeDaDataPhoneClient`, `LeadInjector`, `SnapshotForge`, `ConditionLevers`, scenario-helpers) — в **`Tests\Support\Imitation`** (`app/tests/Support/Imitation/...`), НЕ `App\Support\Imitation` (TDD-гейт блокирует production-path в потоке субагента). Живую сеялку (Task 14) — самодостаточной в `database/seeders` (app-namespace) отдельно.
2. **Коды субъектов — порядковые 1..89, НЕ ГИБДД:** Москва=**82**, СПб=**83**, 77=Тюменская обл. Только через `App\Support\RussianRegions::CODE_TO_NAME`.
3. **Правило для субагентов:** гонять ТОЛЬКО свой `--filter` (`php artisan test --filter=XxxTest`), НИКОГДА весь suite — иначе sequential-pollution (48 известных падений) + пустые сиды + красный sentinel.
4. **Контроллеру** роутер-гейт не даёт запускать `php artisan test`/`cd && …` — все прогоны тестов делают субагенты.
**НОВАЯ Task 0.5 (выполнить ПЕРВОЙ, до Task 2 commit и сценариев): поднять imitation-тест-окружение.**
- Create `app/.env.testing` (DB: postgres / `liderra_dev_pass` / `liderra_testing`) + добавить строку `.env.testing` в `app/.gitignore`.
- Убрать DB_USERNAME/DB_PASSWORD из `app/phpunit.xml` (откат правки субагента №1 — секрет вне git).
- Create `app/tests/Support/Imitation/ImitationTestCase.php` (или trait) — в setup сидит справочные данные: `pricing_tiers` (7 ступеней), пример `phone_ranges` (диапазон → субъект), `suppliers` (b1/b2/b3/direct), при необходимости регионы. Сценарные тесты наследуют его.
- Verify: `php artisan test --filter=FakeDaDataClientTest` и `--filter=LeadInjectorTest` → GREEN.
- Commit env-фикс + Task 2.
**Отложенные хвосты (добить в самом конце):** вычистка пароля из истории ветки (если был запушен — не запушен); финальная регрессия; runbook.
---
## Структура файлов (что создаём / трогаем)
Создаём (всё под тестовый/служебный неймспейс, прод-код НЕ меняем):
- `app/database/seeders/Imitation/ImitationClientsSeeder.php` — сеялка тестовых клиентов/проектов (§6.1, §6.3).
- `app/app/Support/Imitation/FakeDaDataPhoneClient.php` — подставной DaData-клиент (детерминированные ответы по номеру).
- `app/app/Support/Imitation/LeadInjector.php` — инъектор синтетических заявок (через webhook-endpoint).
- `app/app/Support/Imitation/ConditionLevers.php` — рычаги: баланс, лимит, пауза, заморозка, регионы, дни.
- `app/app/Support/Imitation/SnapshotForge.php` — обёртка генерации снапшота (+ форс активной даты).
- `app/app/Console/Commands/Imitation/ImitationSeedCommand.php` — наполнить живой локальный портал для UI-осмотра.
- `app/tests/Feature/Imitation/*.php` — Pest-набор сценариев (по задаче на группу).
- `docs/superpowers/runbooks/2026-06-03-phase1-imitation-runbook.md` — ручной UI-проход + наблюдение естественного цикла + шаблон отчёта.
> **NB по среде:** все Pest-тесты Фазы 1 живут в группе `@group imitation` и НЕ входят в обычный `composer test` (иначе засорят регрессию). Гоняются явно: `php artisan test --group=imitation`.
---
## Task 0: Разведка точных сигнатур (investigation, без кода)
Прежде чем писать «кирпичи», прочитать и зафиксировать точные интерфейсы — чтобы не угадывать.
- [ ] **Step 1: Прочитать DaData-слой**
Read: `app/app/Services/DaData/DaDataPhoneClient.php`, `app/app/Services/DaData/DaDataPhoneResponse.php`, `app/app/Services/DaData/DaDataBudgetGuard.php`.
Зафиксировать: интерфейс/класс `DaDataPhoneClient` (метод `cleanPhone(string): DaDataPhoneResponse`), поля `DaDataPhoneResponse` (`qc`, `region`, `provider`, `raw`).
- [ ] **Step 2: Прочитать резолвер Россвязи + DTO**
Read: `app/app/Services/RossvyazPrefixLookup.php`, `app/app/Services/Dto/RegionResolution.php`, `app/app/Support/DaDataRegionMap.php`, `app/app/Support/RussianRegions.php`.
Зафиксировать: `RossvyazPrefixLookup::find(string $phone)``?RossvyazRecord`; маппинг кодов субъектов.
- [ ] **Step 3: Прочитать фабрики и снапшот-команды**
Read: `app/database/factories/{TenantFactory,UserFactory,ProjectFactory,SupplierProjectFactory}.php`, `app/app/Jobs/SnapshotProjectRoutingJob.php`, `app/app/Console/Commands/SnapshotBackfillCommand.php`.
Зафиксировать: какие поля обязательны у фабрик; как именно запускается генерация снапшота (job dispatch vs artisan); схема `project_routing_snapshots` (колонки `snapshot_date`, `project_id`, `tenant_id`, `daily_limit`, `regions`, `signal_type`, `signal_identifier`, `delivered_count`).
- [ ] **Step 4: Прочитать схему ключевых таблиц**
Read (grep в `db/schema.sql`): `projects`, `tenants`, `supplier_projects`, `project_supplier_links`, `deals`, `lead_charges`, `balance_transactions`, `supplier_lead_costs`, `lead_region_resolution_log`, `phone_ranges`, `pricing_tiers`, `suppliers`, `system_settings`.
Зафиксировать обязательные колонки/CHECK (особенно `chk_supplier_projects_b1_not_for_sms`, `frozen_by_balance_at`, `regions int[]`, `subject_code`, `city`).
- [ ] **Step 5: Зафиксировать находки** в комментарии-шапке `app/app/Support/Imitation/README.md` (создать) — список подтверждённых сигнатур, на которые опираются последующие задачи. Коммит:
```
git add app/app/Support/Imitation/README.md
git commit -m "docs(imitation): pin verified signatures for phase 1 harness"
```
---
## Task 1: Подставной DaData-клиент (детерминированный регион)
**Files:**
- Create: `app/app/Support/Imitation/FakeDaDataPhoneClient.php`
- Test: `app/tests/Feature/Imitation/FakeDaDataClientTest.php`
- [ ] **Step 1: Написать падающий тест**
Тест: связываем `FakeDaDataPhoneClient` как `DaDataPhoneClient` в контейнере, прогоняем `LeadRegionResolver::resolve()` на лиде с номером, для которого фейк отдаёт qc=0 + region='Москва', и проверяем `RegionResolution->source === 'dadata'` и `subjectCode === 77`.
```php
it('resolves dadata branch via fake client', function () {
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient)->stub('79990000077', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(\App\Services\DaData\DaDataPhoneClient::class, $fake);
$lead = SupplierLead::factory()->create(['phone' => '79990000077', 'raw_payload' => ['tag' => '']]);
$res = app(\App\Services\LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('dadata');
expect($res->subjectCode)->toBe(77);
})->group('imitation');
```
- [ ] **Step 2: Прогнать — убедиться, что падает**
Run: `php artisan test --filter=FakeDaDataClientTest`
Expected: FAIL (класс `FakeDaDataPhoneClient` не существует).
- [ ] **Step 3: Реализовать фейк** (сигнатуру `cleanPhone` и поля ответа взять из Task 0 Step 1)
```php
final class FakeDaDataPhoneClient extends \App\Services\DaData\DaDataPhoneClient
{
/** @var array<string, \App\Services\DaData\DaDataPhoneResponse> */
private array $byPhone = [];
public function stub(string $phone, int $qc, ?string $region = null, ?string $provider = null): self
{
$this->byPhone[$phone] = new \App\Services\DaData\DaDataPhoneResponse(
qc: $qc, region: $region, provider: $provider,
raw: ['phone' => $phone, 'qc' => $qc, 'region' => $region, 'provider' => $provider],
);
return $this;
}
public function cleanPhone(string $phone): \App\Services\DaData\DaDataPhoneResponse
{
return $this->byPhone[$phone]
?? throw new \App\Services\DaData\DaDataException("no stub for {$phone}");
}
}
```
> Если `DaDataPhoneClient` — `final` или его конструктор требует аргументы (узнать в Task 0): сделать фейк через общий интерфейс или `Mockery`, а не `extends`. Точную форму подтвердить чтением.
- [ ] **Step 4: Прогнать — убедиться, что проходит**
Run: `php artisan test --filter=FakeDaDataClientTest`
Expected: PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Support/Imitation/FakeDaDataPhoneClient.php app/tests/Feature/Imitation/FakeDaDataClientTest.php
git commit -m "feat(imitation): deterministic fake DaData phone client"
```
---
## Task 2: Инъектор синтетических заявок (через webhook-endpoint)
**Files:**
- Create: `app/app/Support/Imitation/LeadInjector.php`
- Test: `app/tests/Feature/Imitation/LeadInjectorTest.php`
- [ ] **Step 1: Падающий тест** — инъектор шлёт валидную заявку на `POST /api/webhook/supplier/{secret}` (секрет берём из `system_settings`, IP-allowlist на testing fail-open) и получает 202 + создаётся `SupplierLead`.
```php
it('injects a lead via supplier webhook', function () {
$secret = str_repeat('x', 40);
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_webhook_secret'], ['value' => $secret]);
$injector = new LeadInjector($secret);
$resp = $injector->site('vashinvestor.ru', phone: '79991112233', tag: 'Москва', platform: 'B1');
expect($resp->status())->toBe(202);
expect(SupplierLead::where('phone', '79991112233')->exists())->toBeTrue();
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает** (`LeadInjector` не существователь). Run: `php artisan test --filter=LeadInjectorTest` → FAIL.
- [ ] **Step 3: Реализовать инъектор** (поля payload — из `SupplierWebhookController::receive` validate-правил: `vid`, `project`, `phone`, `time`, `tag`, `phones`)
```php
final class LeadInjector
{
public function __construct(private readonly string $secret) {}
public function site(string $domain, string $phone, ?string $tag = null, string $platform = 'B1', ?int $vid = null): \Illuminate\Testing\TestResponse
{
return $this->send("{$platform}_{$domain}", $phone, $tag, $vid);
}
public function call(string $number, string $phone, ?string $tag = null, string $platform = 'B1', ?int $vid = null): \Illuminate\Testing\TestResponse
{
return $this->send("{$platform}_{$number}", $phone, $tag, $vid);
}
private function send(string $project, string $phone, ?string $tag, ?int $vid): \Illuminate\Testing\TestResponse
{
return test()->postJson("/api/webhook/supplier/{$this->secret}", array_filter([
'vid' => $vid ?? random_int(1_000_000, 9_999_999),
'project' => $project,
'phone' => $phone,
'time' => now()->timestamp,
'tag' => $tag,
], fn ($v) => $v !== null));
}
}
```
> `random_int` запрещён в workflow-скриптах, но это обычный Laravel-код — допустимо. Для детерминизма в тестах `vid` передавать явно.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=LeadInjectorTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Support/Imitation/LeadInjector.php app/tests/Feature/Imitation/LeadInjectorTest.php
git commit -m "feat(imitation): synthetic lead injector via supplier webhook"
```
---
## Task 3: Генератор снапшота + рычаги условий
**Files:**
- Create: `app/app/Support/Imitation/SnapshotForge.php`, `app/app/Support/Imitation/ConditionLevers.php`
- Test: `app/tests/Feature/Imitation/SnapshotForgeTest.php`
- [ ] **Step 1: Падающий тест** — после создания проекта `SnapshotForge::rebuild()` создаёт строку в `project_routing_snapshots` за активную дату.
```php
it('builds a routing snapshot for active date', function () {
$project = Project::factory()->create(['is_active' => true, 'daily_limit_target' => 10]);
(new SnapshotForge)->rebuild();
$active = (new SnapshotForge)->activeDate();
expect(DB::connection('pgsql_supplier')->table('project_routing_snapshots')
->where('snapshot_date', $active)->where('project_id', $project->id)->exists())->toBeTrue();
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает.** Run: `php artisan test --filter=SnapshotForgeTest` → FAIL.
- [ ] **Step 3: Реализовать `SnapshotForge`** (механизм генерации — из Task 0 Step 3: dispatch `SnapshotProjectRoutingJob` синхронно ИЛИ вызов `SnapshotBackfillCommand`; активная дата — копия правила из `LeadRouter::activeSnapshotDate`) и `ConditionLevers` (методы: `setBalance(Tenant,$rub)`, `drainBalance(Tenant)`, `fillToLimit(Project)`, `pause(Project)`, `freeze(Tenant)`, `setRegions(Project,array)`, `setDays(Project,int)`).
> Точные вызовы снапшота и колонки — подтвердить по Task 0.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=SnapshotForgeTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Support/Imitation/SnapshotForge.php app/app/Support/Imitation/ConditionLevers.php app/tests/Feature/Imitation/SnapshotForgeTest.php
git commit -m "feat(imitation): snapshot forge + condition levers"
```
---
## Task 4: Сеялка тестовых клиентов и проектов (матрица §6.1 + топологии §6.3)
**Files:**
- Create: `app/database/seeders/Imitation/ImitationClientsSeeder.php`
- Test: `app/tests/Feature/Imitation/SeederTest.php`
- [ ] **Step 1: Падающий тест** — сеялка создаёт 36 одиночных проектов (2 сигнала × 3 региона × 2 дня × 3 лимита) + клиентов под топологии G1/G2/G4; все помечены тестовыми (`name` с префиксом `IMIT-`).
```php
it('seeds the single-project matrix', function () {
(new ImitationClientsSeeder)->run();
expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36);
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает.** Run: `php artisan test --filter=SeederTest` → FAIL.
- [ ] **Step 3: Реализовать сеялку** — цикл по осям (сигнал ∈ {site,call}; регион ∈ {[], [77], [77,78]}; дни ∈ {127, 31}; лимит ∈ {3,30,300}); для каждого — Tenant+User+Project+`project_supplier_links` на общий тестовый `SupplierProject`. Топологии G1/G2/G4 — отдельными методами. Все имена с префиксом `IMIT-`.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=SeederTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/database/seeders/Imitation/ImitationClientsSeeder.php app/tests/Feature/Imitation/SeederTest.php
git commit -m "feat(imitation): test clients + project matrix seeder"
```
---
## Task 5: Региональный каскад резолвера (§7 этап 1: п.9-17)
**Files:**
- Test: `app/tests/Feature/Imitation/RegionResolverCascadeTest.php`
- [ ] **Step 1: Падающие тесты** — по ветке на тест, с `FakeDaDataPhoneClient` (Task 1) и засеянными `phone_ranges`:
- флаг `enabled=false``source='tag'`;
- qc=0 + 'Москва' → `dadata`/77;
- qc=1 → Россвязь (номер в засеянном диапазоне) → `rossvyaz`;
- qc=2 → сразу `tag`;
- DaData бросает `DaDataException` → Россвязь;
- повтор того же номера → `cache_hit=true`, второй раз DaData не зовётся;
- на лид записались `resolved_subject_code`/`region_source`/`dadata_qc`/`phone_operator`.
```php
it('falls through to rossvyaz on qc=1', function () {
config(['services.dadata.enabled' => true]);
// засеять phone_ranges так, чтобы 79995550011 → субъект 78 (см. Task 0 формат)
app()->instance(DaDataPhoneClient::class, (new FakeDaDataPhoneClient)->stub('79995550011', qc: 1));
$lead = SupplierLead::factory()->create(['phone' => '79995550011', 'raw_payload' => ['tag' => '']]);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz');
expect($res->rossvyazMatched)->toBeTrue();
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падают** (если резолвер на копии работает иначе → это уже найденный баг, фиксируем в отчёт). Run: `php artisan test --filter=RegionResolverCascadeTest`.
- [ ] **Step 3: Анализ результатов** — это ПРОВЕРОЧНЫЕ тесты против существующего боевого кода, не TDD-разработка. Если ветка ведёт себя не по спеку — записать находку в runbook-отчёт (Task 13), не «чинить тест».
- [ ] **Step 4: Коммит тестов**
```
git add app/tests/Feature/Imitation/RegionResolverCascadeTest.php
git commit -m "test(imitation): region resolution cascade coverage"
```
---
## Task 6: Сценарий A — взвешенный жребий по объёму (+ X2 статистика)
**Files:**
- Test: `app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php`
- [ ] **Step 1: Тест распределения** — 5 клиентов на одном источнике, один регион, остатки лимита {300,30,30,3,3}; сидируем `Randomizer` (Mt19937) детерминированно; прогоняем N=300 заявок через инъектор; считаем доли получателей.
```php
it('splits leads weighted by remaining limit, small client > 0', function () {
// bind Randomizer with fixed Mt19937 seed (см. LeadRouter конструктор)
// seed 5 tenants/projects on one supplier_project, regions=[77], limits as above
// inject 300 leads with phone resolving to subject 77
// assert: big client got most; smallest client count > 0; shares roughly ∝ limits
})->group('imitation');
```
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioA_WeightedLotteryTest`. Зафиксировать фактические доли в отчёт.
- [ ] **Step 3: Коммит**
```
git add app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php
git commit -m "test(imitation): scenario A weighted lottery + distribution stats"
```
---
## Task 7: Сценарии B/C — каскад по региону (фазы 1/2)
**Files:** Test: `app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php`
- [ ] **Step 1: Тесты** — (B) клиенты с `regions=[77]` + клиент `regions=[]`: лид субъекта 77 → точному (`routing_step=1`), лид субъекта 50 (ни у кого точного) → клиенту «вся РФ» (`routing_step=2`). (C) каждому свой регион → лид уходит только своему. Проверять `deals.subject_code` и `routing_step` через `lead_region_resolution_log`.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioBC_RegionCascadeTest`. Находки — в отчёт.
- [ ] **Step 3: Коммит** `test(imitation): scenarios B/C region cascade`.
---
## Task 8: Сценарий D — дни доставки
**Files:** Test: `app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php`
- [ ] **Step 1: Тест** — два клиента на источнике; одному `delivery_days_mask` БЕЗ сегодняшнего дня (через `ConditionLevers::setDays` + пересборка снапшота); лид уходит только активному сегодня.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioD_DeliveryDaysTest`.
- [ ] **Step 3: Коммит** `test(imitation): scenario D delivery days`.
---
## Task 9: Сценарии E1/E2/F — две заморозки + лимит
**Files:** Test: `app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php`
- [ ] **Step 1: Тесты:**
- **E1** — клиент с балансом ниже цены лида: после доставки `InsufficientBalance` → проект `is_active=false`, письмо `ZeroBalancePaused` поставлено (Mail::fake), заявка ушла следующему.
- **E2** — клиент с `frozen_by_balance_at` (через `ConditionLevers::freeze`): исключён из подбора ещё на этапе фильтра (в подборе его нет).
- **F** — клиент с `delivered_today = snapshot.daily_limit`: выбывает, заявка другим.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioEF_FreezeLimitTest`.
- [ ] **Step 3: Коммит** `test(imitation): scenarios E1/E2/F freezes + limit`.
---
## Task 10: Сценарий G3 — «осиротевшая» заявка
**Files:** Test: `app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php`
- [ ] **Step 1: Тест** — один источник, все клиенты приведены в негодность (пауза/лимит/чужой регион); инъектируем лид; проверяем: сделок 0, списаний 0, `SupplierLead.processed_at` проставлен, `deals_created_count=0`, исключений нет; запись о лиде сохранена (видно «непроданный» лид).
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioG3_OrphanLeadTest`. Зафиксировать: где именно «оседает» непроданный лид.
- [ ] **Step 3: Коммит** `test(imitation): scenario G3 orphan lead`.
---
## Task 11: G5a/b/c + G6 — особые заявки и дубли
**Files:** Test: `app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php`
- [ ] **Step 1: Тесты:** G5a (qc=2/7 → tag), G5b (DaData недоступен/qc=1 → Россвязь), G5c (ни DaData, ни Россвязь, пустой тег → `unknown`), G6 (две заявки с одним `vid` → второй ответ 200 «already_processed», вторая сделка не создаётся).
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioG5G6_SpecialLeadsTest`.
- [ ] **Step 3: Коммит** `test(imitation): scenarios G5/G6 special leads + dedup`.
---
## Task 12: X1 — подмена региона на шаге 3 + журнал; X3 — сводка источника
**Files:** Test: `app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php`
- [ ] **Step 1: Тесты:**
- **X1** — на источнике только клиент(ы) с конкретным регионом, отличным от региона лида, и НЕТ клиента «вся РФ» → каскад уходит в фазу 3; проверяем: `deals.subject_code` подменён на регион клиента, `deals.city` = имя НАСТОЯЩЕГО региона лида, `lead_region_resolution_log.actual_subject_code` = настоящий, `substituted_subject_code` заполнен, `routing_step=3`.
- **X3** — прогнать смесь лидов и собрать сводку `region_source` (dadata/rossvyaz/tag/unknown) из `lead_region_resolution_log`.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioX1X3_SubstitutionJournalTest`.
- [ ] **Step 3: Коммит** `test(imitation): X1 step-3 substitution + X3 source breakdown`.
---
## Task 13: Топологии G1/G2/G4 + деньги + приём
**Files:** Test: `app/tests/Feature/Imitation/TopologyMoneyIntakeTest.php`
- [ ] **Step 1: Тесты:**
- **G1/G2/G4** — один клиент на нескольких источниках; паутина; один клиент 2 проекта на одном источнике с разными регионами — проверяем корректность подбора в каждом узле.
- **Деньги** — после доставки: `lead_charges` (ступень/цена/`charge_source='rub'`), `balance_transactions` (отрицательная сумма + остаток), `supplier_lead_costs`; bcmath без потери копеек; цена по `delivered_in_month+1`.
- **Приём** — неверный секрет → 404; флуд → 429; `time` вне ±24ч → отказ; телефон не `7\d{10}` → 422.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=TopologyMoneyIntakeTest`.
- [ ] **Step 3: Коммит** `test(imitation): topologies + money + intake checks`.
---
## Task 14: Команда наполнения живого портала (UI-осмотр)
**Files:**
- Create: `app/app/Console/Commands/Imitation/ImitationSeedCommand.php`
- Test: `app/tests/Feature/Imitation/ImitationSeedCommandTest.php`
- [ ] **Step 1: Падающий тест**`artisan imitation:seed` запускает `ImitationClientsSeeder`, биндит `FakeDaDataPhoneClient`, сеет `phone_ranges`, генерит снапшот и инъектирует пачку лидов; завершается кодом 0; в БД появляются сделки.
```php
it('populates the running portal for UI review', function () {
$this->artisan('imitation:seed', ['--leads' => 20])->assertExitCode(0);
expect(Deal::where('status', 'new')->count())->toBeGreaterThan(0);
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает.** Run: `php artisan test --filter=ImitationSeedCommandTest` → FAIL.
- [ ] **Step 3: Реализовать команду** — собрать «кирпичи» (Tasks 1-4) в один сценарий заполнения; защита `if (app()->environment('production')) { abort }` — НИКОГДА не на проде.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=ImitationSeedCommandTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Console/Commands/Imitation/ImitationSeedCommand.php app/tests/Feature/Imitation/ImitationSeedCommandTest.php
git commit -m "feat(imitation): imitation:seed command to populate local portal"
```
---
## Task 15: Runbook + отчёт + регрессия
**Files:**
- Create: `docs/superpowers/runbooks/2026-06-03-phase1-imitation-runbook.md`
- [ ] **Step 1: Написать runbook** — пошагово: (1) сверка Шаг 0 (прод-коммит, роли, справочники, флаги); (2) `php artisan imitation:seed`; (3) ручной UI-проход глазами клиента (логин, проекты, лента сделок, смена статуса, экспорт CSV/XLSX, баланс, тарифы, уведомление-колокольчик); (4) наблюдение естественного цикла (форс снапшота, сброс `delivered_today`, прогон `CsvReconcileJob`); (5) шаблон отчёта «ожидали / получили / находки».
- [ ] **Step 2: Прогнать весь набор сценариев**
Run: `php artisan test --group=imitation`
Expected: все зелёные ИЛИ список находок (расхождений с ожиданием) для отчёта.
- [ ] **Step 3: Регрессия проекта** (имитация ничего не сломала)
Run: `composer test` (Pest --parallel) и `npm run test:vue`
Expected: GREEN (группа `imitation` исключена из обычного прогона).
- [ ] **Step 4: Заполнить отчёт** в runbook: что проверено, какие находки, что починено.
- [ ] **Step 5: Коммит**
```
git add docs/superpowers/runbooks/2026-06-03-phase1-imitation-runbook.md
git commit -m "docs(imitation): phase 1 runbook + results report"
```
---
## Self-Review (выполнено при написании)
**1. Покрытие спека:** §6.1 матрица → Task 4; §6.2 A→Task6, B/C→Task7, D→Task8, E1/E2/F→Task9, G3→Task10; §6.3 топологии→Task13; §6.4 G5/G6→Task11; §6.5 X1/X3→Task12 (X2→Task6, X4 опц. — не отдельной задачей, помечен в спеке как необязательный); §7 этап0→Task13, этап1→Task5, этап2→Task6-8/12, этап3→Task7/12, этап4→Task9/13, этап5→Task12/13, этап6→Task10/11, этап7→Task15; Шаг 0 сверка→Task15 Step1 + (база кода — OQ-1, до старта). DaData-замена→Task1; снапшот→Task3; рычаги→Task3; инъектор→Task2.
**2. Призраки:** точные сигнатуры DaData-клиента, Россвязи, фабрик, снапшота и колонок НЕ выдуманы — вынесены в Task 0 как обязательная разведка; в задачах, где код зависит от них, стоит явная отсылка «подтвердить по Task 0».
**3. Согласованность имён:** `FakeDaDataPhoneClient`, `LeadInjector`, `SnapshotForge`, `ConditionLevers`, `ImitationClientsSeeder`, `imitation:seed`, группа `imitation` — единообразны во всех задачах.
**Известные пробелы (осознанные):** X4 (граница месяца) — опционально, не отдельной задачей; CSV-импорт клиентом и исходящий webhook — вне Фазы 1 (см. спек §3).
@@ -1,149 +0,0 @@
# Runbook — Имитация портала, Фаза 1 (репетиция у себя)
**Дата:** 0304.06.2026. **Ветка:** `worktree-prod-imitation-clients` (база `origin/main` `bd7b1d3e`).
**Спек:** `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
**План:** `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md`
Цель Фазы 1 — посмотреть на портал глазами клиента на копии (= боевой код) и поймать
логические ошибки до Фазы 2. Деньги — локальные, DaData — подставная/выключенная.
---
## 1. Сверка «копия = боевой» (Шаг 0)
| Что | Как проверить |
|---|---|
| Код | Копия на `origin/main` + ветка `worktree-prod-imitation-clients`. Региональная фича влита. |
| Схема БД | `db/schema.sql` (полная текущая) грузится миграцией `0001`; дельта-миграции идемпотентны. |
| Роли БД | На dev — `postgres` superuser; `pgsql_supplier` фоллбэчит на него (RLS/BYPASSRLS как на проде). |
| Справочники | `pricing_tiers` (7 ступеней, `PricingTierSeeder`), `phone_ranges` (по требованию), поставщики b1/b2/b3/direct (миграции). |
| Тест-БД | `liderra_testing` пересобирается `migrate:fresh` (см. ниже). |
### Пересборка тест-БД (если нужна чистая)
```bash
cd app
DB_DATABASE=liderra_testing DB_USERNAME=postgres DB_PASSWORD=liderra_dev_pass \
php artisan migrate:fresh --force
```
> **NB.** `migrate:fresh` чинится тремя вещами (коммит `22f6178b`): `MonthlyPartitionManager::ensureMonth`
> пропускает партиционированную таблицу без существующего родителя; миграция `0001` идёт с
> `$withinTransaction=false` (DDL коммитится до `partitions:create-months`); дельта-миграции
> `add_balance_freeze` / `add_paused_at` идемпотентны (`DROP POLICY IF EXISTS` / проверки колонки/индекса).
> **НИКОГДА не запускать `migrate:fresh` посреди работы субагентов** — это общая тест-БД.
---
## 2. Наполнить живой локальный портал (`imitation:seed`)
```bash
cd app
php artisan imitation:seed --leads=20 --clients=3
```
Команда (app-namespace, **запрещена на production**): создаёт несколько профинансированных
тестовых клиентов на общем источнике B2, выключает DaData (регион берётся из тега), пересобирает
снапшот за активную дату и инжектирует синтетические заявки через **настоящий**
`RouteSupplierLeadJob` — появляются сделки, списания, уведомления, как в проде.
Опции: `--leads=N` (число заявок), `--clients=N` (число клиентов).
---
## 3. Ручной проход глазами клиента (UI)
1. Логин / 2FA.
2. Проекты — список, карточка проекта.
3. Лента сделок — новые сделки (`status='new'`), карточка сделки (телефон, **Город** = настоящий
регион лида даже при подмене региона на шаге 3).
4. Смена статуса сделки.
5. Экспорт CSV / XLSX.
6. Баланс — текущий остаток, история списаний (`balance_transactions`).
7. Тарифы — текущая ступень (по `delivered_in_month`).
8. Колокольчик — уведомление о новой сделке; при нулевом балансе — письмо «нулевой баланс».
---
## 4. Наблюдение естественного цикла (время форсим)
- **Слепок:** смена настройки сегодня → эффект со следующего снапшота. Форсить:
`php artisan snapshot:rebuild --date=<активная_дата>` (DELETE+INSERT).
- **Сброс `delivered_today`** в 00:00 МСК — обнуление дневных счётчиков.
- **`CsvReconcileJob`** (ежечасно) — дрейф > 5% → алерт.
---
## 5. Шаблон отчёта «ожидали / получили / находки»
| Сценарий | Ожидали | Получили | Вывод |
|---|---|---|---|
| … | … | … | OK / находка |
---
## 6. Результаты Фазы 1 (заполнено)
### Автоматический набор сценариев — `php artisan test --group=imitation`
**Все 54 теста / 194 assertions — GREEN** (36с, изоляция через `DatabaseTransactions`).
Покрытие по плану:
| Группа | Файл | Результат |
|---|---|---|
| Кирпичи | FakeDaData / LeadInjector / SnapshotForge / ConditionLevers / Seeder | GREEN |
| Регион-каскад резолвера (§7 эт.1) | `RegionResolverCascadeTest` (11) | GREEN |
| A — взвешенный жребий + X2 | `ScenarioA_WeightedLotteryTest` | P0(300)→76% / P3,P4(3)→3 каждый (мелкий не отрезан) |
| B/C — каскад по региону | `ScenarioBC_RegionCascadeTest` (4) | exact step1 + all-RF step2; изоляция по регионам |
| D — дни доставки | `ScenarioD_DeliveryDaysTest` | неактивный сегодня отсутствует в снапшоте |
| E1/E2/F — заморозки + лимит | `ScenarioEF_FreezeLimitTest` (3) | auto-pause на InsufficientBalance; заморозка/лимит — фильтр роутера |
| G3 — осиротевшая заявка | `ScenarioG3_OrphanLeadTest` | оседает в `supplier_leads` (`processed_at`, `deals_created_count=0`, `error=NULL`) |
| G5/G6 — особые + дубли | `ScenarioG5G6_SpecialLeadsTest` (9) | qc→source; дедуп vid → 200 already_processed |
| X1/X3 — подмена + журнал | `ScenarioX1X3_SubstitutionJournalTest` (2) | step-3 подмена subject_code, Город=настоящий регион, журнал actual/substituted |
| Топологии + деньги + приём | `TopologyMoneyIntakeTest` (11) | G1/G2/G4 без утечек; деньги bcmath без копеек; приём 404/422/429 |
| Живая команда | `ImitationSeedCommandTest` | exit 0 + сделки создаются |
### Находки (главная ценность Фазы 1)
- **F1 (инфраструктура, починено `4dfcde99`):** `ImitationTestCase::seedPhoneRange()` использовал
несуществующие колонки (`range_from/range_to/region_name`) и не заполнял FK `import_id`
любой Россвязь-тест падал. Исправлено на реальные колонки `from_num/to_num/...` + anchor-импорт.
- **F2/F3 (план vs реальность, кода не трогали):** план §6.4/§7 говорит «qc=2/7 / флаг off → `tag`»,
по факту резолвер возвращает `unknown`, если тег пустой (`tag` — только когда тег = валидный
регион). Россвязь при qc=2/7 не зовётся — это верно. Тесты утверждают реальное поведение.
- **Денежная корректность (главная ставка) — чиста:** `lead_charges` (ступень/цена/`charge_source='rub'`),
`balance_transactions` (минус + остаток), `supplier_lead_costs`; списание bcmath без потери копеек;
цена по `delivered_in_month+1`; tier-граница (100 → ступень 2). Подтверждено `TopologyMoneyIntakeTest`.
- **Подмена региона на шаге 3 — корректна:** `deals.subject_code` = регион клиента, `deals.city` =
имя НАСТОЯЩЕГО региона лида, журнал `actual_subject_code`/`substituted_subject_code`, `routing_step=3`.
- **Осиротевший лид «невидим»:** ищется только запросом
`supplier_leads WHERE deals_created_count=0 AND processed_at IS NOT NULL AND error IS NULL`
(не в `failed_webhook_jobs`). Стоит иметь админ-вид «непроданные лиды».
- **Инжект через payload:** `RouteSupplierLeadJob` ре-резолвит supplier из `raw_payload['project']`
по `(platform, unique_key)` (`parseProjectField``resolveOrStub`); `unique_key` должен быть
доменом, чтобы распарситься как `site`. Учтено в `LeadInjector` и `imitation:seed`.
- **Worktree-env (не код):** `app/.env` в этом worktree содержит APP_KEY неверной длины →
существующий `SupplierWebhookTest` и шифрование падают; тесты, которым нужно шифрование, чинят
ключ в `beforeEach`. Это проблема окружения копии, не прода.
### Проектная регрессия (имитация ничего не сломала)
- `composer test` (single-process): 22 падения — **пре-существующее single-process загрязнение**,
не из имитации. Подтверждено: `ProjectExtensionsTest` 6/6 и `SupplierProjectTest` 6/6 **в изоляции**
зелёные; `IncidentsWatchFailures` — задокументированный polluter (CLAUDE.md). Малые count'ы (4/5),
не 36+ → имитационные тесты не текут (rollback `DatabaseTransactions` работает).
- `pest --parallel` (CI-режим, где загрязнение исчезает) — **в этом Windows-worktree падает на
bootstrap paratest** (container error, env-проблема). На CI/Linux — штатный режим.
- Прод-правки имитации (clean migrate:fresh fix) verified: `MonthlyPartitionManagerTest` 15/15 +
`migrate:fresh` проходит полностью.
- Фронтенд имитацией не затронут.
---
## 7. Хвосты (открыты, не блокируют Фазу 1)
- Чистка bootstrap-`beforeEach` в `app/tests/Feature/Imitation/SeederTest.php` (теперь no-op после
восстановления БД — можно упростить до обычного паттерна).
- Удаление пароля БД из `app/phpunit.xml` (вынести в `.env.testing`; ⚠️ `.env.testing` грузится
Laravel ВМЕСТО `.env` → нужен полный env, а не только DB-creds; проверить fallback на untracked `.env`).
- Прод-выкатка региональной фичи / Фаза 2 — отдельно.
@@ -1,211 +0,0 @@
# Дизайн: имитация работы портала глазами клиента — ФАЗА 1 (репетиция у себя)
**Дата:** 03.06.2026
**Статус:** design (черновик на согласовании, ревизия 2 — выверен по боевому коду `origin/main`)
**Автор:** brainstorm-сессия с владельцем (Дмитрий)
**Ветка:** `worktree-prod-imitation-clients`
---
## 1. Зачем это нужно (простыми словами)
Хотим посмотреть на портал **глазами клиента** — будто наши клиенты зарегистрировались
и начали работать: создают проекты, на них льются заявки, портал их раздаёт, списывает
деньги, ведёт отчёты. Цель — убедиться, что весь рабочий цикл портала ведёт себя правильно
во всех значимых ситуациях, и поймать ошибки **до** того, как они проявятся на реальных
клиентах и реальных деньгах.
## 2. Общая схема: две фазы (контекст)
- **Фаза 1 — «репетиция у себя» (этот документ).** На точной копии портала сами создаём
клиентов, сами шлём придуманные заявки, сами создаём нужные ситуации. Деньги и сервис
определения региона (DaData) — заменены (локальные начисления / подставной клиент), чтобы
ошибка ничего не стоила и не пачкала боевое. Задача — выловить логические ошибки.
- **Фаза 2 — «вживую» (отдельный документ, позже).** Боевой портал, 5-6 тестовых клиентов,
неделя, реальные заявки на отдельно выделенные источники, расписание + сверка. **Не входит.**
## 3. Граница Фазы 1
**Входит:** копия портала + сверка с боевым; тестовый стенд; полный прогон проверок (§7) на
всех значимых ситуациях (§6); поиск ошибок → починка → перепрогон.
**Не входит (осознанно):**
- Фаза 2 (боевой недельный прогон).
- Аспекты, которые на Windows-копии не воспроизвести: `pg_audit`, `pg_anonymizer`
(маскирование/аудит-журнал БД), пулер PgBouncer — только Фаза 2.
- Реальные внешние вызовы (платный DaData, реальные деньги) — заменяются.
- **Исходящая доставка лида клиенту по webhook НЕ проверяется как внешний вызов** — по факту
кода `OutboundWebhookSubscription` — это только настройка, в пути доставки лида она НЕ
задействована (push не реализован). Клиент видит лиды в CRM / через API. Внешнего исходящего
вызова мокать не нужно.
- **CSV-импорт лидов клиентом** (`ImportController`/`ImportLeadsJob`) — отдельный вход, **не в
Фазе 1** (тестовые клиенты — покупатели лидов, не импортёры). CSV-сверка поставщика
(`CsvReconcileJob`) и CSV-merge в доставке — проверяем (см. §7), сам импорт клиентом — нет.
## 4. Среда Фазы 1 и сверка «копия = боевой» (Шаг 0)
Смысл Фазы 1 — что ошибка на копии есть и на боевом. Поэтому до прогонов сверяем:
| Что сверяем | Как / условие |
|---|---|
| **Стартовая точка кода** | копия на коммите, идентичном задеплоенному на прод. Регион-фича влита в `origin/main` (каскад + взвешенный жребий + резолвер) — копию поднимаем на ней. Точный прод-коммит подтверждается перед стартом (OQ-1). |
| **Схема БД** | таблицы / индексы / RLS / функции / триггеры / партиции идентичны. |
| **Роли БД** | локально создаём те же 5 ролей (`db/00_create_roles.sql`) — доступ через `pgsql_supplier` и RLS должны вести себя как на проде. |
| **Справочники** | реестр Россвязи (`phone_ranges`, нормализованные регионы), тарифные ступени, карта регионов (89 субъектов), сидовые поставщики (`b1`/`b2`/`b3`/`direct`). |
| **Настройки** | расписание cron, тайминг слепка 18:00/21:00 МСК. |
**Замены внешних зависимостей в Фазе 1:**
- **DaData (определение региона).** По факту кода `LeadRegionResolver`: каскад
DaData → Россвязь → тег, под флагом `services.dadata.enabled`. Заменяем **подставным
`DaDataPhoneClient`** (биндим в контейнер, отдаёт заранее заданные `DaDataPhoneResponse`
qc/регион/оператор по номеру), флаг `enabled=true`. Это гоняет **все ветки каскада**
(qc 0/3 маппится → dadata; qc 0/3 ambiguous/не-маппится → Россвязь; qc 1 / таймаут / 5xx →
Россвязь; qc 2/7 → tag) детерминированно и бесплатно. Для ветки «Россвязь» сеем `phone_ranges`.
- **Деньги.** Баланс начисляем сами (админ-функция). Списания идут по-настоящему по коду
(тарифные ступени, bcmath, обе заморозки), но это локальные цифры — реальных рублей нет.
**Прерывание-критично (Шаг 0, найдено при выверке):** маршрутизатор берёт ВСЁ из таблицы
снапшота `project_routing_snapshots` за активную дату. **Нет снапшота → ни одна заявка никуда
не уйдёт** (только лог ошибки `lead_router.no_snapshot_for_active_date`). Поэтому setup Фазы 1
ОБЯЗАН сгенерировать снапшот (`SnapshotProjectRoutingJob` / `SnapshotBackfillCommand` /
`SnapshotRebuildCommand`) после создания проектов и после каждой смены настроек. Слепок-инвариант:
смена настройки сегодня → эффект со следующего снапшота (18:00/21:00 МСК), поэтому Фаза 1 должна
уметь **двигать время / пересобирать снапшот**, иначе сценарии со сменой настроек не отработают
за один прогон.
**Известные неустранимые расхождения копии:** `pg_audit`, `pg_anonymizer`, PgBouncer — на
Windows-копии не ставятся, уходят в Фазу 2.
## 5. Тестовый стенд (инструменты имитации)
1. **Сеялка** — тестовые клиенты (тенанты) + пользователи + проекты по матрице (§6.1) +
расстановка по топологиям (§6.3) + начисление баланса.
2. **Генерация снапшота** — обёртка над `SnapshotProjectRoutingJob`/backfill/rebuild; вызывается
после сеялки и после смены настроек; умеет «активную дату» (слепок-инвариант).
3. **«Пушка заявок»** — отправляет придуманные заявки на вход портала (тот же webhook-endpoint
поставщика) с управляемыми полями: сигнал (сайт/телефон), источник (B1/B2/B3/DIRECT), телефон,
тег, `vid`, `time`.
4. **Подставной `DaDataPhoneClient`** — биндится в контейнер, возвращает заданный
`DaDataPhoneResponse` (qc/регион/оператор) по номеру → детерминированный регион-каскад.
5. **«Рычаги условий»** — приводят клиентов в нужное состояние: начислить/обнулить баланс;
добить `delivered_today` до лимита; поставить проект на паузу (`is_active=false`);
**заморозить тенанта по балансу** (`frozen_by_balance_at`); сменить регионы/дни; задать тариф.
Все инструменты — тестовые/служебные команды, боевое не трогают.
## 6. Какие ситуации прогоняем (полное покрытие)
Покрытие в Фазе 1 — **максимальное** (безопасно и бесплатно).
### 6.1. Матрица одиночного проекта
Оси (по форме создания проекта; СМС исключена решением владельца):
| Ось | Значения |
|---|---|
| Сигнал | сайт / телефон (2) |
| Регион | вся РФ / один субъект / несколько субъектов (3) |
| Дни доставки | все 7 / частично (2) |
| Дневной лимит | низкий / средний / высокий (3) |
Полный перебор = **2 × 3 × 2 × 3 = 36** одиночных проектов.
### 6.2. Сценарии конкуренции (один источник делят несколько клиентов) — главное
| | Сценарий | Что проверяем | Механика портала |
|---|---|---|---|
| **A** | один источник → 4-5 клиентов, разные объёмы, один регион | деление по остатку лимита; мелкого не отрезают | **взвешенный жребий** (вес = остаток, ≥ 1), cap = 3 |
| **B** | один источник → точные регионы + клиент «вся РФ» | региону — точному (фаза 1), остаток — на «вся РФ» (фаза 2) | каскад фазы 1→2 |
| **C** | один источник → каждому свой регион | каждому только его заявки (фаза 1 по своему субъекту) | каскад фаза 1 |
| **D** | один источник → часть клиентов сегодня не работает | делят только активные сегодня | фильтр дней в снапшоте |
| **E1** | один источник → у клиента кончился баланс на доставке | проект → пауза (`is_active=false`), письмо 1/час, заявка идёт следующему | auto-pause на `InsufficientBalance` |
| **E2** | один источник → клиент заморожен по балансу (`frozen_by_balance_at`) | заморожённый исключён из подбора ещё на этапе фильтра | отдельный механизм заморозки |
| **F** | один источник → клиент упёрся в дневной лимит | выбывание, остаток другим | `delivered_today ≥ snapshot.daily_limit` |
| **G3** | один источник → все три фазы пусты (никто не подошёл) | «осиротевшая» заявка: никому, портал не падает, не списывает; видно ли её | пустой каскад (фаза 3 тоже пуста) |
### 6.3. Топологии
| | Топология |
|---|---|
| **G1** | один клиент сидит на нескольких источниках сразу |
| **G2** | паутина: много клиентов ↔ много источников одновременно |
| **G4** | один клиент держит 2 проекта на одном источнике с разными регионами |
### 6.4. Особые заявки (своя придуманная заявка)
| | Случай | Что проверяем |
|---|---|---|
| **G5a** | DaData вернул мусор/иностранца (qc 2/7) | каскад уходит сразу в tag |
| **G5b** | DaData недоступен/таймаут/qc 1 | каскад деградирует на Россвязь (по `phone_ranges`) |
| **G5c** | ни DaData, ни Россвязь не дали код | tag-fallback; пустой тег → `unknown` |
| **G6** | одна и та же заявка дважды (один `vid`) | защита от дублей (200 «already_processed», без второй сделки) |
### 6.5. Расширения (идеи владельца, добавлены в покрытие)
| | Проверка |
|---|---|
| **X1** | **Подмена региона на шаге 3 глазами клиента:** при запасном канале сделке ставят регион клиента (`subject_code` подменён), но «Город» в карточке = НАСТОЯЩИЙ регион лида, а настоящий субъект — в `lead_region_resolution_log.actual_subject_code` + флаг подмены. Проверяем, что клиент видит правильный город и подмена зафиксирована в журнале. |
| **X2** | **Статистика взвешенного жребия:** прогнать много заявок на сценарий A и убедиться, что доли получателей близки к долям остатков лимита, а мелкий клиент получает > 0. |
| **X3** | **Сводка по источнику региона:** сколько лидов определилось через dadata / rossvyaz / tag / unknown (поле `region_source` + журнал). |
| **X4** | **Граница месяца (опц.):** тариф зависит от `delivered_in_month`; проверить смену тарифной ступени при переходе через границу месяца. *(на усмотрение — может быть перебор для Фазы 1.)* |
## 7. Полный список проверок поведения (выверен по боевому коду `origin/main`)
Источники: `SupplierWebhookController`, `RouteSupplierLeadJob`, `LeadRegionResolver`,
`LeadRouter`, `LeadDistributor`, `LedgerService`.
### Этап 0. Приём заявки (`SupplierWebhookController`)
1. Неверный секрет → 404. 2. IP вне белого списка → 404 (на проде пустой = режем всех; на копии — пускаем). 3. Флуд > 600/мин с IP → 429. 4. `time` за пределами ±24 ч → отклонить (защита партиции). 5. Телефон не `7XXXXXXXXXX` → 422. 6. Повтор по `vid` → 200 «already_processed». 7. Проект без `B1/B2/B3` → DIRECT. 8. Запись в `webhook_log` на каждый исход.
### Этап 1. Определение региона лида (`LeadRegionResolver`) — НОВОЕ
9. Флаг `services.dadata.enabled=false` → сразу tag (старое поведение).
10. Уже резолвили на прошлой попытке (`resolved_subject_code`/`region_source` есть) → без повторного DaData (идемпотентность, защита от двойной оплаты).
11. qc 0/3 + регион маппится и не ambiguous → `source=dadata`.
12. qc 0/3 + ambiguous/не-маппится → Россвязь (оператор от DaData сохраняем).
13. qc 1 / таймаут / 5xx / бюджет исчерпан → Россвязь.
14. qc 2/7 → сразу tag.
15. Россвязь нашла префикс → `source=rossvyaz`; не нашла → tag; пустой тег → `unknown`.
16. На лид пишутся `resolved_subject_code`, `region_source`, `dadata_qc`, `phone_operator`.
17. Кэш по sha256(phone) — повтор того же номера не ходит в DaData (`cache_hit`).
### Этап 2. Подбор получателей (`LeadRouter` каскад + взвешенный жребий) — ПЕРЕПИСАНО
18. Берутся только проекты из снапшота активной даты: `delivered_today < snapshot.daily_limit`, баланс > 0, `frozen_by_balance_at IS NULL`, подписан на источник; один проект на клиента (наибольший остаток).
19. **Фаза 1** — точное совпадение субъекта (`resolved_subject_code = ANY(snap.regions)`), только если резолвер дал код. Помечается `routing_step=1`.
20. **Фаза 2** — «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов, исключая уже выбранных клиентов. `routing_step=2`.
21. **Фаза 3** — запасной канал (без фильтра региона), только если фазы 1+2 пусты. `routing_step=3`.
22. **Взвешенный жребий** внутри фазы при кандидатах > cap: шанс ∝ остатку лимита, **вес ≥ 1** (мелкий клиент не отрезан); cap = 3 (лид максимум 3 разным клиентам). Детерминизм в тестах через сид Mt19937.
23. Снапшота на активную дату нет → лог ошибки. 24. Все три фазы пусты → заявка никому (G3).
### Этап 3. Доставка каждому выбранному (транзакция под блокировками)
25. Проект на паузе после слепка (`is_active=false` под локом) → не доставляем. 26. `delivered_today ≥ snapshot.daily_limit` под локом → пропуск. 27. CSV-догон: webhook после CSV-восстановленной сделки → объединяем без повторного списания; **если источник webhook достовернее тега (dadata/rossvyaz) — обновляем регион/оператора/город сделки**. 28. Одна доставка одному клиенту строго один раз. 29. Создание сделки (статус «new», `phones[]`).
30. **Подмена региона на шаге 3:** `routing_step<3``subject_code` = настоящий резолв; `routing_step=3``subject_code` подменяется на регион клиента, а `city` = имя НАСТОЯЩЕГО региона; настоящий субъект → в журнал (`actual_subject_code`).
### Этап 4. Деньги (`LedgerService`, always-rub) + две заморозки
31. Цена по тарифной ступени = `delivered_in_month + 1`. 32. **Заморозка 1:** `frozen_by_balance_at` → отказ списания → auto-pause (та же ветка, что недостаток баланса). 33. bcmath: `balance_rub×100 ≥ цена`, иначе отказ — без потери копеек. 34. Списание `balance_rub -= цена`, `delivered_in_month++`. 35. Записи в `lead_charges` / `balance_transactions` / `supplier_lead_costs`. 36. **Заморозка 2 (auto-pause):** недостаток баланса на доставке → проект `is_active=false` (через BYPASSRLS) + письмо «нулевой баланс» (1/час/клиент) + переход к следующему клиенту.
### Этап 5. Счётчики, аудит, уведомления, журнал региона
37. `delivered_today++`, `delivered_in_month++`, `snapshot.delivered_count++`. 38. `ActivityLog` (создание сделки). 39. Аудит ПДн (152-ФЗ, `PdAuditLogger`). 40. Уведомление клиенту + колокольчик. 41. **Журнал региона** `lead_region_resolution_log` — одна строка на лид (`subject_code_resolved`, `subject_code_from_tag`, `region_source`, `dadata_qc`, `rossvyaz_matched`, `actual_subject_code`, `substituted_subject_code`, `routing_step`, `cache_hit`, маскированный телефон); **fail-safe** — сбой журнала НЕ роняет доставку.
### Этап 6. Падения и шторма
42. Все выбранные упали → исключение → 3 попытки → `failed_webhook_jobs`. 43. Заявка удалена/уже обработана/терминальная ошибка → без шторма повторов.
### Этап 7. Естественный цикл (наблюдаем; время форсим)
44. Слепок: смена настроек → эффект со следующего снапшота. 45. Сброс `delivered_today` в 00:00 МСК. 46. `CsvReconcileJob` (ежечасно): дрейф > 5% → алерт. 47. Клиентский интерфейс: регистрация/2FA, проекты, лента сделок, смена статуса, экспорт CSV/XLSX, напоминания, баланс, тарифы.
## 8. Критерий успеха Фазы 1
- Каждый пункт §7 на ситуациях §6 ведёт себя как ожидается (фиксируем «ожидали / получили»).
- Найденные ошибки задокументированы, починены, прогон повторён до чистоты.
- Регрессия проекта зелёная (Pest, Vitest, сборка).
- Понятный отчёт: что проверили, что нашли, что починили.
## 9. Открытые вопросы
- **OQ-1.** Точный прод-коммит для сверки (Шаг 0) подтверждается перед стартом.
- **OQ-2.** Где поднимать копию: native-Windows (быстро, без расширений/пулера — принято для логики) или Linux-копия ближе к проду.
- **OQ-3.** Объём «пушки заявок» на сценарий (особенно X2 — статистика жребия) — уточняется в плане.
- **OQ-4.** Подставной DaData: задаём ответы кодом (фабрика сценариев) — формат фикстур уточняется в плане.
## 10. Что дальше
После согласования — подробный **план работ** (`superpowers:writing-plans`).
-194
View File
@@ -47,200 +47,6 @@
{
"url": "http://localhost:8000/500",
"screenCapture": "./bin/a11y-screenshots/live-07-500.png"
},
{
"url": "http://localhost:8000/dashboard",
"screenCapture": "./bin/a11y-screenshots/live-auth-08-dashboard.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard"
]
},
{
"url": "http://localhost:8000/deals",
"screenCapture": "./bin/a11y-screenshots/live-auth-09-deals.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/deals",
"wait for path to be /deals"
]
},
{
"url": "http://localhost:8000/kanban",
"screenCapture": "./bin/a11y-screenshots/live-auth-10-kanban.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/kanban",
"wait for path to be /kanban"
]
},
{
"url": "http://localhost:8000/projects",
"screenCapture": "./bin/a11y-screenshots/live-auth-11-projects.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/projects",
"wait for path to be /projects"
]
},
{
"url": "http://localhost:8000/billing",
"screenCapture": "./bin/a11y-screenshots/live-auth-12-billing.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/billing",
"wait for path to be /billing"
]
},
{
"url": "http://localhost:8000/settings",
"screenCapture": "./bin/a11y-screenshots/live-auth-13-settings.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/settings",
"wait for path to be /settings"
]
},
{
"url": "http://localhost:8000/reports",
"screenCapture": "./bin/a11y-screenshots/live-auth-14-reports.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/reports",
"wait for path to be /reports"
]
},
{
"url": "http://localhost:8000/reminders",
"screenCapture": "./bin/a11y-screenshots/live-auth-15-reminders.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/reminders",
"wait for path to be /reminders"
]
},
{
"url": "http://localhost:8000/admin/tenants",
"screenCapture": "./bin/a11y-screenshots/live-auth-16-admin-tenants.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/admin/tenants",
"wait for path to be /admin/tenants"
]
},
{
"url": "http://localhost:8000/admin/billing",
"screenCapture": "./bin/a11y-screenshots/live-auth-17-admin-billing.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/admin/billing",
"wait for path to be /admin/billing"
]
},
{
"url": "http://localhost:8000/admin/incidents",
"screenCapture": "./bin/a11y-screenshots/live-auth-18-admin-incidents.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/admin/incidents",
"wait for path to be /admin/incidents"
]
},
{
"url": "http://localhost:8000/admin/system",
"screenCapture": "./bin/a11y-screenshots/live-auth-19-admin-system.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/admin/system",
"wait for path to be /admin/system"
]
},
{
"url": "http://localhost:8000/admin/pricing-tiers",
"screenCapture": "./bin/a11y-screenshots/live-auth-20-admin-pricing-tiers.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/admin/pricing-tiers",
"wait for path to be /admin/pricing-tiers"
]
},
{
"url": "http://localhost:8000/admin/supplier-prices",
"screenCapture": "./bin/a11y-screenshots/live-auth-21-admin-supplier-prices.png",
"actions": [
"navigate to http://localhost:8000/login",
"wait for element input[autocomplete=\"email\"] to be visible",
"set field input[autocomplete=\"email\"] to admin@demo.local",
"set field input[autocomplete=\"current-password\"] to password",
"click element button[type=\"submit\"]",
"wait for path to be /dashboard",
"navigate to http://localhost:8000/admin/supplier-prices",
"wait for path to be /admin/supplier-prices"
]
}
]
}
+1 -1
View File
@@ -19,7 +19,7 @@ import {
} from './enforce-hook-helpers.mjs';
import { verifyRealTestContent } from './tdd-real-test-verifier.mjs';
const TEST_FILE_RE = /.(?:test|spec)\.[a-z0-9]+$/i;
const TEST_FILE_RE = /.(?:test|spec).[a-z0-9]+$/i;
function readEditedFiles(sessionId) {
try {
@@ -36,40 +36,4 @@ describe('enforce-tdd-real-test-verifier decide()', () => {
});
expect(r.block).toBe(false);
});
it('.env.testing is NOT treated as a test file (second dot fix)', () => {
const r = decide({
filePath: '.env.testing',
content: 'DB_PASSWORD=secret',
editedFiles: [],
});
expect(r.block).toBe(false);
});
it('app/Foo.test.mjs with no expect is still gated (real test file)', () => {
const r = decide({
filePath: 'app/Foo.test.mjs',
content: "const x = 1;",
editedFiles: [],
});
expect(r.block).toBe(true);
});
it('real PHP test with expect passes through', () => {
const r = decide({
filePath: 'app/tests/Feature/BarTest.php',
content: "it('x', fn()=>expect(1)->toBe(1));",
editedFiles: [],
});
expect(r.block).toBe(false);
});
it('foo.spec.ts with no expect is still gated (spec variant)', () => {
const r = decide({
filePath: 'resources/js/foo.spec.ts',
content: 'const x = 1;',
editedFiles: [],
});
expect(r.block).toBe(true);
});
});
-16
View File
@@ -40,22 +40,6 @@ export function extractTestMetrics(stdout) {
// Pest: "Tests: 742 passed (1908 assertions)"
m = stdout.match(/Tests:\s+(\d+)\s+passed/);
if (m) { out.tests_passed = +m[1]; out.tests_total = +m[1]; out.tests_failed = 0; return out; }
// Pest JSON reporter: {"tool":"pest","result":"passed","tests":14,"passed":14,"assertions":22}
// {"tool":"pest","result":"failed","tests":15,"passed":14,"errors":1,...}
// Mirrors the JSON recognition added to enforce-tdd-gate.mjs in commit 1d2d43a6.
const jres = stdout.match(/"result"\s*:\s*"(passed|failed)"/);
if (jres) {
const pM = stdout.match(/"passed"\s*:\s*(\d+)/);
const fM = stdout.match(/"failed"\s*:\s*(\d+)/);
const eM = stdout.match(/"errors"\s*:\s*(\d+)/);
const tM = stdout.match(/"tests"\s*:\s*(\d+)/);
out.tests_passed = pM ? +pM[1] : null;
let failed = (fM ? +fM[1] : 0) + (eM ? +eM[1] : 0);
if (jres[1] === 'failed' && failed === 0) failed = 1; // result=failed but no count → force >=1
out.tests_failed = failed;
out.tests_total = tM ? +tM[1] : (out.tests_passed ?? null);
return out;
}
return out;
}
+1 -47
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extractTestMetrics, decideRecord } from './enforce-verify-record.mjs';
import { extractTestMetrics } from './enforce-verify-record.mjs';
describe('enforce-verify-record / extractTestMetrics — Vitest skipped formats', () => {
it('parses vitest passed-only with skipped', () => {
@@ -16,49 +16,3 @@ describe('enforce-verify-record / extractTestMetrics — Vitest skipped formats'
});
});
});
describe('enforce-verify-record / extractTestMetrics — Pest JSON reporter', () => {
it('parses Pest JSON passed line: tests_passed=14, tests_failed=0, tests_total=14', () => {
const stdout = '{"tool":"pest","result":"passed","tests":14,"passed":14,"assertions":22,"duration_ms":2034}';
expect(extractTestMetrics(stdout)).toMatchObject({
tests_passed: 14,
tests_failed: 0,
tests_total: 14,
});
});
it('parses Pest JSON failed line: tests_failed >= 1', () => {
const stdout = '{"tool":"pest","result":"failed","tests":15,"passed":14,"assertions":22,"errors":1,"duration_ms":3000}';
const m = extractTestMetrics(stdout);
expect(m.tests_failed).toBeGreaterThanOrEqual(1);
});
it('Pest JSON result=failed with no explicit failed/errors count → forced fail (>=1)', () => {
const m = extractTestMetrics('{"tool":"pest","result":"failed","tests":3,"passed":2}');
expect(m.tests_failed).toBeGreaterThanOrEqual(1);
});
});
describe('enforce-verify-record / decideRecord — Pest JSON reporter', () => {
it('decideRecord with passed JSON + exitCode null → result pass', () => {
const result = decideRecord({
toolName: 'Bash',
command: 'php artisan test --filter=X',
exitCode: null,
stdout: '{"tool":"pest","result":"passed","tests":14,"passed":14,"assertions":22}',
});
expect(result).not.toBeNull();
expect(result.result).toBe('pass');
});
it('decideRecord with failed JSON → result fail', () => {
const result = decideRecord({
toolName: 'Bash',
command: 'php artisan test --filter=X',
exitCode: 1,
stdout: '{"tool":"pest","result":"failed","tests":15,"passed":14,"assertions":22,"errors":1}',
});
expect(result).not.toBeNull();
expect(result.result).toBe('fail');
});
});