Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ebc20ff94 | |||
| 28d2d38857 | |||
| 09f16bd83c | |||
| 512d8e0e24 | |||
| 7aa0e4169e | |||
| 7c9a8151f6 | |||
| be36fc64b3 | |||
| d883bf486f | |||
| 8907d16e40 | |||
| 364065a239 | |||
| 000bf816cc | |||
| 339c5f09f7 | |||
| 7a49291296 | |||
| e3f6227ed1 | |||
| 7b8535eef2 | |||
| 69c1c5b374 | |||
| 8e804cc482 | |||
| 0bf69ce6b5 | |||
| 07747713f0 | |||
| c6d2df908a | |||
| d4ade05446 |
@@ -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
|
||||
|
||||
@@ -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 не парсил
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
@@ -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';
|
||||
|
||||
-2
@@ -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 давно истёк; для свежих — близко к реальной паузе).
|
||||
|
||||
@@ -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 433–453:
|
||||
* $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 558–595:
|
||||
* $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');
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
АВС/ DEF;От;До;Емкость;Оператор;Регион
|
||||
495;2000000;2009999;10000;ОАО МГТС;г. Москва
|
||||
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
|
||||
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
|
||||
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 (репетиция у себя)
|
||||
|
||||
**Дата:** 03–04.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`).
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user