Compare commits

..

56 Commits

Author SHA1 Message Date
Дмитрий d772fafbb1 chore(imitation): remove DB password from tracked phpunit.xml (B4)
DB_USERNAME/DB_PASSWORD now come from the untracked local .env (dev creds postgres/liderra_dev_pass that already match liderra_testing on the same local Postgres). phpunit.xml keeps only the non-secret DB_DATABASE/DB_CONNECTION override. Verified: tests still connect (FakeDaDataClientTest 3/3 GREEN) without the env vars in phpunit.xml. .env.testing remains gitignored.
2026-06-04 05:21:39 +03:00
Дмитрий 932360b526 docs(imitation): phase 1 runbook + results report
Manual UI walkthrough, imitation:seed usage, natural-cycle observation, report template, and the filled Phase 1 results: imitation suite 54/54 GREEN; findings (F1 seedPhoneRange fixed, F2/F3 plan-vs-resolver tag/unknown, money bcmath kopeck-clean, step-3 substitution, orphan-lead resting place); regression note (22 single-process failures are pre-existing pollution, confirmed green in isolation; imitation prod changes verified).
2026-06-04 05:20:22 +03:00
Дмитрий 669e161017 feat(imitation): imitation:seed command to populate local portal
Self-contained app-namespace artisan command (NEVER on production) that funds local imitation clients on a shared B2 supplier, disables DaData (region from tag), rebuilds the routing snapshot, then injects synthetic leads through the real RouteSupplierLeadJob so deals/charges/notifications appear for hands-on UI review. The lead payload encodes the supplier unique_key as a domain so RouteSupplierLeadJob re-resolves the real supplier (parseProjectField then resolveOrStub). Test asserts exit 0 + new deals.
2026-06-04 05:09:29 +03:00
Дмитрий 61de9ae9a8 test(imitation): topologies + money + intake checks 2026-06-04 04:58:20 +03:00
Дмитрий 49ea46ab0e test(imitation): X1 step-3 substitution + X3 source breakdown 2026-06-04 04:45:37 +03:00
Дмитрий d5e966eebc test(imitation): scenarios G5/G6 special leads + dedup 2026-06-04 04:38:12 +03:00
Дмитрий a00c2da479 test(imitation): scenario G3 orphan lead 2026-06-04 04:29:35 +03:00
Дмитрий 5720458f7b test(imitation): scenarios E1/E2/F freezes + limit 2026-06-04 04:25:00 +03:00
Дмитрий 19a425e20f test(imitation): scenario D delivery days 2026-06-04 04:19:30 +03:00
Дмитрий 27bc60be47 test(imitation): scenarios B/C region cascade 2026-06-04 04:14:57 +03:00
Дмитрий 4dfcde99ba fix(imitation): correct seedPhoneRange columns + import_id FK (F1)
ImitationTestCase::seedPhoneRange used non-existent columns (range_from/range_to/region_name) and omitted the required import_id FK, so every Россвязь-branch test that called it failed. Now seeds a phone_ranges_imports anchor row and inserts phone_ranges with the real columns (def_code/from_num/to_num/operator/region/subject_code/imported_at/import_id), mirroring the verified RossvyazPrefixLookup parsing. Found during Task 5.
2026-06-04 03:54:22 +03:00
Дмитрий f55c224d6a test(imitation): scenario A weighted lottery + distribution stats 2026-06-03 20:21:22 +03:00
Дмитрий 2969f3720f test(imitation): region resolution cascade coverage 2026-06-03 20:08:49 +03:00
Дмитрий 22f6178b2b fix(migrations): clean migrate:fresh resilience (partition parent guard + delta idempotency)
Restores a working migrate:fresh without the reverted blanket catch-all. (1) MonthlyPartitionManager::ensureMonth skips a partitioned table whose parent does not exist yet (targeted pg_class relkind='p' guard) instead of crashing — the initial schema-load runs partitions:create-months before later delta-migrations create their own partitioned tables. (2) migration 0001 runs with $withinTransaction=false so the schema.sql DDL is committed before partitions:create-months opens its second pgsql_supplier connection. (3) re-applies the clean idempotency guards on add_balance_freeze (DROP POLICY IF EXISTS) and add_paused_at (column/index existence checks) since schema.sql already contains those objects. migrate:fresh now rebuilds liderra_testing cleanly; MonthlyPartitionManagerTest 15/15 incl. new resilience guard test.
2026-06-03 20:01:55 +03:00
Дмитрий 544e9e589c feat(imitation): test clients + single-project matrix seeder 2026-06-03 19:37:49 +03:00
Дмитрий 40629276d9 feat(imitation): snapshot forge + condition levers 2026-06-03 19:09:12 +03:00
Дмитрий f8d89e81d1 revert(imitation): drop out-of-scope migration edits from 7c5ca7f6 + dead webhook_log test
Reverts 7c5ca7f6 production-migration edits (load_initial_schema withinTransaction=false + try/catch, idempotency guards) and coupled migrate:fresh guard tests to baseline; imitation suite uses DatabaseTransactions on a pre-migrated DB so the reverted migrate:fresh resilience is not needed for Phase 1. Also drops the orphaned ensureMonth webhook_log test (webhook_log removed from PARTITIONED_TABLES in 2026_05_24_140000_drop_legacy_webhook_artefacts).
2026-06-03 18:58:13 +03:00
Дмитрий 64e962e330 fix(hooks): parse Pest JSON reporter in verify-record + escape dot in tdd-real-test regex
enforce-verify-record extractTestMetrics now recognises the project Pest JSON reporter ({"result":"passed/failed",...}); previously every Pest run was recorded as a failed sentinel, blocking all Pest-verified commits (mirrors enforce-tdd-gate fix 1d2d43a6). enforce-tdd-real-test-verifier TEST_FILE_RE second dot escaped so .env.testing is no longer false-matched as a test file.
2026-06-03 18:51:02 +03:00
Дмитрий 619dc691a9 docs(imitation): session handoff for phase 1 resume (worktree, state, 2 open decisions, subagent rules) 2026-06-03 16:57:18 +03:00
Дмитрий 7c5ca7f688 chore(imitation): Task 0.5 — test env, reference-seed base, migrate:fresh resilience 2026-06-03 16:49:23 +03:00
Дмитрий e03da647c0 docs(imitation): plan — execution status + corrections (namespace, subject codes, Task 0.5 env provision) 2026-06-03 16:25:52 +03:00
Дмитрий a54b0346e9 feat(imitation): deterministic fake DaData phone client 2026-06-03 15:35:46 +03:00
Дмитрий bad947a5b8 docs(imitation): Task 0 — pin verified signatures + plan corrections 2026-06-03 14:57:48 +03:00
Дмитрий dee4a0e1a2 docs(imitation): phase 1 client-imitation spec + implementation plan 2026-06-03 14:52:08 +03:00
CoralMinister bd7b1d3e0f Merge pull request #43 from CoralMinister/feat/deals-city-region
Feat/deals city region
2026-06-02 13:48:18 +03:00
CoralMinister 57e9541775 Merge pull request #42 from CoralMinister/feat/gate-allow-worktree-cd
Feat/gate allow worktree cd
2026-06-02 13:47:47 +03:00
Дмитрий e213f9b01c feat(deals): backfill command for «Город» on existing deals
deals:backfill-region-city fills deals.city from the lead resolved_subject_code (deals -> supplier_lead_deliveries -> supplier_leads) for deals where city is still empty, idempotently and across all tenants (BYPASSRLS). --dry-run reports the count without writing. Whitelisted in artisan-run.yml (dry-run read-only; real run requires confirm_apply). TDD: +4 tests GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:38:10 +03:00
Дмитрий 1609faee8c feat(deals): fill «Город» (deals.city) with resolved region name
The UI «Город» column binds to deals.city but nothing ever populated it — the region was only stored as a numeric code on supplier_leads + the resolution log. RouteSupplierLeadJob now writes the resolved subject name (RussianRegions::CODE_TO_NAME) into deals.city on deal creation (the lead's real region, even if subject_code is substituted on routing step 3), and updates it in the CSV-merge branch when the webhook resolution outranks the tag. New deals now display the region. TDD: +2 tests in RouteSupplierLeadJobTest; 24 job tests GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:16:31 +03:00
CoralMinister 237eae7ee0 Merge pull request #41 from CoralMinister/feat/gate-dev-prod-rescope
Feat/gate dev prod rescope
2026-06-02 09:41:03 +03:00
CoralMinister 34b85cf5cc Add files via upload 2026-06-02 08:11:37 +03:00
CoralMinister e2c00d60b1 Add files via upload 2026-06-01 19:07:51 +03:00
CoralMinister 97938c66b2 Add files via upload 2026-06-01 18:48:18 +03:00
CoralMinister 9c8db287ad Add files via upload 2026-06-01 18:11:59 +03:00
CoralMinister b404bf41a8 Add files via upload 2026-06-01 18:10:26 +03:00
CoralMinister d821bfb235 Add files via upload 2026-06-01 18:05:01 +03:00
CoralMinister cc149f324d Add files via upload 2026-06-01 18:01:02 +03:00
CoralMinister 6bd2735973 Add files via upload 2026-06-01 16:26:02 +03:00
CoralMinister 8c50c6db52 Add files via upload 2026-06-01 16:10:59 +03:00
CoralMinister 2000985208 Add files via upload 2026-06-01 14:15:34 +03:00
CoralMinister 544c06a790 Add files via upload 2026-06-01 13:49:51 +03:00
CoralMinister c67c217e43 Add files via upload 2026-06-01 11:10:06 +03:00
CoralMinister a24d084c24 Merge pull request #30 from CoralMinister/worktree-feat+lead-region-resolution
Worktree feat+lead region resolution
2026-06-01 10:51:31 +03:00
Дмитрий 1107979168 chore(region): add cspell dictionary terms (DaData/Rossvyaz) 2026-06-01 07:39:43 +03:00
Дмитрий 849e467924 fix(region): wrap phone_ranges swap in a transaction + drop stray comment (code-review) 2026-06-01 07:32:15 +03:00
Дмитрий c959c03f55 docs(region): rollout runbook + session progress 2026-06-01 07:21:24 +03:00
Дмитрий 893a142812 feat(region): phone-region:smoke staging command 2026-06-01 07:21:15 +03:00
Дмитрий dae2085ea0 feat(region): RouteSupplierLeadJob — resolve region + persist + fail-safe log + step-3 substitution + CSV-merge 2026-06-01 07:21:08 +03:00
Дмитрий 048f3ad6a2 feat(region): Deal — region_substituted + phone_operator fields 2026-06-01 07:21:01 +03:00
Дмитрий 8be1db34b8 feat(region): LeadRouter cascade routing (exact→all-RF→fallback) + weighted pick variant В + routing_step 2026-06-01 07:19:54 +03:00
Дмитрий 9e05d8f728 test(region): createRoutingSnapshotFromProject accepts regions param 2026-06-01 07:19:46 +03:00
Дмитрий 4bb94257cf feat(region): LeadRegionResolver orchestrator (full qc cascade) 2026-06-01 07:19:37 +03:00
Дмитрий b91b6d5008 feat(region): DaData layer (region map, config, enum, client, budget guard) 2026-06-01 07:19:29 +03:00
Дмитрий b822042a66 feat(region): phone-ranges:import command (parse/map/dry-run/idempotency) 2026-06-01 07:18:23 +03:00
Дмитрий b25aa025e4 feat(region): RossvyazPrefixLookup + RossvyazRecord DTO 2026-06-01 07:18:17 +03:00
Дмитрий 635d631eae chore(region): sync db/schema.sql + CHANGELOG (v8.40) 2026-06-01 07:18:09 +03:00
Дмитрий ec21971888 feat(region): schema migration + MonthlyPartitionManager registration 2026-06-01 07:12:08 +03:00
106 changed files with 463674 additions and 1184 deletions
+15 -140
View File
@@ -38,42 +38,12 @@
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|Skill|Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-llm-judge-per-tool.mjs",
"timeout": 30
}
]
},
{
"matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-safe-baseline-metering.mjs",
"timeout": 10
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-runtime-write-deny.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md Р’В§5 Р С—.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
},
@@ -82,7 +52,7 @@
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
},
@@ -176,6 +146,16 @@
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
@@ -195,71 +175,6 @@
"timeout": 5
}
]
},
{
"matcher": "Workflow",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-workflow-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-decomposition-detector.mjs",
"timeout": 8
},
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-safe-baseline-metering.mjs",
"timeout": 10
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-runtime-write-deny.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
}
],
"PostToolUse": [
@@ -277,7 +192,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md Р’В§5 Р С—.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
},
@@ -291,7 +206,7 @@
},
{
"type": "command",
"command": "echo ok",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
@@ -301,7 +216,7 @@
"hooks": [
{
"type": "command",
"command": "echo ok",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
@@ -315,29 +230,9 @@
"timeout": 10
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-askuser-answer-parser.mjs",
"timeout": 2
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-llm-judge-response-scan.mjs",
"timeout": 30
}
]
},
{
"hooks": [
{
@@ -382,15 +277,6 @@
"timeout": 10
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
}
],
"UserPromptSubmit": [
@@ -423,17 +309,6 @@
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
}
]
}
}
Binary file not shown.
+2 -2
View File
@@ -45,10 +45,10 @@ jobs:
echo "Requested: '$CMD_TRIM'"
# Group 1 — read-only / dry-run / inspection: всегда разрешены
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run)( *)$'
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run|deals:backfill-region-city --dry-run)( *)$'
# Group 2 — mutating: требуют confirm_apply=true
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?)( *)$'
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?|deals:backfill-region-city)( *)$'
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
echo "::notice::Command in read-only whitelist — proceeding."
+393
View File
@@ -0,0 +1,393 @@
name: Lead region — prod ops
# Самодостаточный launch-инструмент фичи lead-region-resolution.
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
#
# op:
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
# superuser (crm_app_user не член crm_migrator → обычный migrate
# падает) + пометить применённой, чтобы deploy её пропустил.
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
#
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
on:
workflow_dispatch:
inputs:
op:
description: 'Операция'
required: true
type: choice
options:
- pre-migrate
- set-env
- fetch-rossvyaz
- fetch-via-runner
- deliver-from-repo
- import
- smoke
flag:
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
url:
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
required: false
type: string
dir:
description: 'import: каталог с CSV на проде'
required: false
default: '/var/www/liderra/rossvyaz'
type: string
dry_run:
description: 'import: только staging без swap'
required: false
default: true
type: boolean
phone:
description: 'smoke: телефон'
required: false
default: '79161234567'
type: string
jobs:
op:
name: ${{ github.event.inputs.op }}
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: liderra-prod-deploy
cancel-in-progress: false
env:
LIDERRA_HOST: 111.88.246.137
LIDERRA_USER: ubuntu
APP_DIR: /var/www/liderra/app
OP: ${{ github.event.inputs.op }}
FLAG: ${{ github.event.inputs.flag }}
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
PHONE: ${{ github.event.inputs.phone }}
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
chmod 600 ~/.ssh/liderra_deploy
ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Checkout repo (for deliver-from-repo)
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
uses: actions/checkout@v4
- name: op=pre-migrate (superuser DDL + mark applied)
if: ${{ github.event.inputs.op == 'pre-migrate' }}
run: |
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
BEGIN;
-- 1. phone_ranges_imports (FK target — создаём первым)
CREATE TABLE phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE phone_ranges_imports IS
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
CREATE TABLE phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
CREATE TABLE lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
phone_operator TEXT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
COMMENT ON TABLE lead_region_resolution_log IS
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
CREATE TABLE lead_region_resolution_log_y2026_m05
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- 4. supplier_leads: +4 колонки
ALTER TABLE supplier_leads
ADD COLUMN resolved_subject_code SMALLINT
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
ADD COLUMN region_source TEXT
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
ADD COLUMN dadata_qc SMALLINT,
ADD COLUMN phone_operator TEXT;
-- 5. deals: +2 колонки
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
-- ownership как у миграции (она шла бы под crm_migrator)
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
ALTER TABLE phone_ranges OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
-- retention (system_settings, 12 мес)
INSERT INTO system_settings (key, value, type, description, updated_at)
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
WHERE NOT EXISTS (
SELECT 1 FROM system_settings
WHERE key = 'partition_retention_months_lead_region_resolution_log');
COMMIT;
SQLEOF
)
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1")
if [ "${ALREADY}" = "1" ]; then
echo "Migration ${MIG_NAME} уже применена — пропускаю."
exit 0
fi
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
if [ "${TABLE_EXISTS}" != "1" ]; then
echo "Применяю lead-region DDL через postgres superuser..."
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
else
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
fi
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
sudo -u postgres psql -d liderra -c \
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')"
echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
echo "=== Проверка таблиц ==="
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
REMOTE
- name: op=set-env (keys from secrets + flag → prod .env)
if: ${{ github.event.inputs.op == 'set-env' }}
env:
DK: ${{ secrets.DADATA_API_KEY }}
DS: ${{ secrets.DADATA_SECRET }}
run: |
DK_B64=$(printf '%s' "$DK" | base64 -w0)
DS_B64=$(printf '%s' "$DS" | base64 -w0)
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
ENV="${APP_DIR}/.env"
DK=$(echo "$DK_B64" | base64 -d)
DS=$(echo "$DS_B64" | base64 -d)
upsert() {
local key="$1" val="$2"
sudo sed -i "/^${key}=/d" "$ENV"
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
}
upsert DADATA_API_KEY "$DK"
upsert DADATA_SECRET "$DS"
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
cd "$APP_DIR"
sudo -u www-data php artisan config:clear
sudo -u www-data php artisan config:cache
sudo systemctl restart liderra-queue
echo "set-env готово: flag=${FLAG}, ключи записаны."
echo "=== Проверка (значения скрыты) ==="
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
echo "=== queue status ==="
systemctl is-active liderra-queue || true
REMOTE
- name: op=fetch-rossvyaz (download registry on prod)
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
run: |
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
# Непустой url → качаем только его (ручной режим).
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
DEST=/var/www/liderra/rossvyaz
sudo mkdir -p "$DEST"
cd "$DEST"
if [ -n "$URL" ]; then
URLS="$URL"
else
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
fi
for U in $URLS; do
FNAME=$(basename "${U%%\?*}")
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
echo "Скачиваю $U -> $FNAME"
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
case "$FNAME" in
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
esac
done
sudo chown -R www-data:www-data "$DEST"
echo "=== Содержимое $DEST ==="
ls -lh "$DEST"
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
if [ -n "$FIRST_CSV" ]; then
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
fi
REMOTE
- name: op=fetch-via-runner (download on runner, ship to prod)
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
run: |
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
FN=$(basename "${U%%\?*}")
echo "runner: скачиваю $U -> $FN"
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
done
echo "=== скачано на runner ==="
ls -lh /tmp/rv | tee /tmp/op.log
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
run: |
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
if [ ${#FILES[@]} -eq 0 ]; then
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
fi
echo "=== файлы в репозитории (rossvyaz-data/) ==="
ls -lh "${FILES[@]}" | tee /tmp/op.log
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
cd /tmp/rvup
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
sudo mkdir -p /var/www/liderra/rossvyaz
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
echo "=== на проде /var/www/liderra/rossvyaz ==="
ls -lh /var/www/liderra/rossvyaz
' | tee -a /tmp/op.log
- name: op=import (phone-ranges:import)
if: ${{ github.event.inputs.op == 'import' }}
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; 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
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 "=== Счётчики ==="
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
if [ "$STAGING_EXISTS" = "t" ]; then
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
else
echo "staging: отсутствует (после свапа — норма)"
fi
echo "=== Последний импорт ==="
sudo -u postgres psql -d liderra -c \
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
REMOTE
- name: op=smoke (phone-region:smoke)
if: ${{ github.event.inputs.op == 'smoke' }}
run: |
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-region:smoke --phone=${PHONE} ==="
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
REMOTE
- name: Print summary
if: always()
run: |
{
echo "## lead-region-ops: \`${OP}\`"
echo
echo '```'
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/liderra_deploy
+69526
View File
File diff suppressed because it is too large Load Diff
+150000
View File
File diff suppressed because it is too large Load Diff
+142791
View File
File diff suppressed because it is too large Load Diff
+73783
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -292,7 +292,7 @@ trivy image liderra:latest
## 6. Текущая фаза проекта
**2026-06-02 lead region resolution — ВЫКАЧЕНА на прод liderra.ru (флаг ON, 100%):** Определение настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback) + каскадная маршрутизация по региону (exact→all-RF→fallback) со взвешенным жребием по остатку дневного лимита (вариант В, вес ≥ 1 — мелкие клиенты не отрезаются). Состав: `LeadRegionResolver` (каскад по qc-кодам DaData) + `DaData/*` (клиент / страж бюджета / enum кодов качества / исключения) + `DaDataRegionMap` + `RossvyazPrefixLookup` + `RossvyazRecord`/`RegionResolution` DTO + команда `phone-ranges:import` (parse/map/dry-run/idempotency, atomic RENAME-swap в транзакции) + `LeadRouter` переписан (`matchEligibleProjects` + region-фильтр + `weightedPick` + инъекция `Randomizer`) + интеграция в `RouteSupplierLeadJob` (резолв ДО tx / persist 4 колонки / fail-safe аудит-лог `lead_region_resolution_log` / подмена subject_code на шаге 3 / CSV-merge по рангу источника `dadata/rossvyaz > CSV-tag`) + `phone-region:smoke`. Миграция `2026_05_31_100000` (`phone_ranges` / `phone_ranges_imports` / `lead_region_resolution_log` партиц. по месяцам + колонки на supplier_leads/deals) + регистрация в `MonthlyPartitionManager`; `db/schema.sql` синхронизирован заголовком (v8.40), DDL — в дельта-миграции (иначе двойной CREATE TABLE сломал бы migrate). **Решения заказчика/проекта:** резолвер через `app()` внутри `handle()` (не 7-й параметр — сохраняет сигнатуру + 3 существующих теста джобы); `deals.region_source` не добавляли (источник на supplier_leads + в журнале, CSV-merge по эвристике); запуск сразу на 100% без долевого гейта. **14 атомарных коммитов** `ec219718..11079791` на ветке `worktree-feat+lead-region-resolution`, запушено в origin (`CoralMinister/lidpotok`), **PR в main открывается вручную** по ссылке из `git push` output (MCP `create_pull_request` + `gh pr create` оба заблокированы router-гейтом). Тесты **101 pest GREEN / 509 assertions**; tools-vitest **1989 GREEN**. Code-review subagent (вердикт «с правками») → починены `atomicSwap()`→транзакция (spec §6.2) + убран stray comment; minor/deferred задокументированы (метрики §8.1 / `phone-ranges:rollback` / pg_anonymizer-маски / калибровка `DADATA_CALL_COST_KOPECKS`). **ВЫКАЧЕНО на прод liderra.ru 01.06.2026 ~16:01 МСК** (по команде «запускаем»): ключи DaData в боевом `.env`, флаг `LEAD_REGION_RESOLVER_ENABLED=true` на **100%** потока, реестр Россвязи **453080 диапазонов** (atomic swap). Smoke на реальном номере ✓ (Источник=dadata, Субъект=29 Красноярский край, qc=0). Инструмент выкатки — `.github/workflows/lead-region-ops.yml` (на origin/main, локально untracked). Откат: op=set-env flag=false. Операционные уроки и хвосты (unmapped-регионы → код+TDD, косметика счётчика, мониторинг region_source, калибровка DADATA_CALL_COST_KOPECKS) — память [[project-lead-region-resolution-live]]; runbook `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`. Пре-существующий долг (НЕ из фичи, отдельная задача): 3 чужих console-теста (`BillingMigrateLeadsToRub` / `IncidentsWatchFailures` / `SnapshotBackfillCommand`) взаимно загрязняются в одном процессе (накопление счётчиков), в CI `pest --parallel` (файл=процесс) проходят. **Lefthook в worktree-shell не в PATH** → cspell/larastan/squawk/deptrac не гонялись на коммитах; deptrac проверен инспекцией (Service→Service разрешён), новые cspell-термины (Rossvyaz/DaData/kopecks) добавлены в `cspell-words.txt`, остальное — CI на push. **§0 cross-refs НЕ меняются** — app-фича (сервисы/джоба/миграция), не tooling-канон #1-#86 / не ADR / не off-phase. Через `claude-md-management:revise-claude-md`.
**2026-06-01 lead region resolution — фича реализована TDD + запушена (PR в main):** Определение настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback) + каскадная маршрутизация по региону (exact→all-RF→fallback) со взвешенным жребием по остатку дневного лимита (вариант В, вес ≥ 1 — мелкие клиенты не отрезаются). Состав: `LeadRegionResolver` (каскад по qc-кодам DaData) + `DaData/*` (клиент / страж бюджета / enum кодов качества / исключения) + `DaDataRegionMap` + `RossvyazPrefixLookup` + `RossvyazRecord`/`RegionResolution` DTO + команда `phone-ranges:import` (parse/map/dry-run/idempotency, atomic RENAME-swap в транзакции) + `LeadRouter` переписан (`matchEligibleProjects` + region-фильтр + `weightedPick` + инъекция `Randomizer`) + интеграция в `RouteSupplierLeadJob` (резолв ДО tx / persist 4 колонки / fail-safe аудит-лог `lead_region_resolution_log` / подмена subject_code на шаге 3 / CSV-merge по рангу источника `dadata/rossvyaz > CSV-tag`) + `phone-region:smoke`. Миграция `2026_05_31_100000` (`phone_ranges` / `phone_ranges_imports` / `lead_region_resolution_log` партиц. по месяцам + колонки на supplier_leads/deals) + регистрация в `MonthlyPartitionManager`; `db/schema.sql` синхронизирован заголовком (v8.40), DDL — в дельта-миграции (иначе двойной CREATE TABLE сломал бы migrate). **Решения заказчика/проекта:** резолвер через `app()` внутри `handle()` (не 7-й параметр — сохраняет сигнатуру + 3 существующих теста джобы); `deals.region_source` не добавляли (источник на supplier_leads + в журнале, CSV-merge по эвристике); запуск сразу на 100% без долевого гейта. **14 атомарных коммитов** `ec219718..11079791` на ветке `worktree-feat+lead-region-resolution`, запушено в origin (`CoralMinister/lidpotok`), **PR в main открывается вручную** по ссылке из `git push` output (MCP `create_pull_request` + `gh pr create` оба заблокированы router-гейтом). Тесты **101 pest GREEN / 509 assertions**; tools-vitest **1989 GREEN**. Code-review subagent (вердикт «с правками») → починены `atomicSwap()`→транзакция (spec §6.2) + убран stray comment; minor/deferred задокументированы (метрики §8.1 / `phone-ranges:rollback` / pg_anonymizer-маски / калибровка `DADATA_CALL_COST_KOPECKS`). Прод-выкатка отложена (нужны `DADATA_API_KEY`/`DADATA_SECRET` в YC Lockbox + команда «запускаем»; runbook `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`). Пре-существующий долг (НЕ из фичи, отдельная задача): 3 чужих console-теста (`BillingMigrateLeadsToRub` / `IncidentsWatchFailures` / `SnapshotBackfillCommand`) взаимно загрязняются в одном процессе (накопление счётчиков), в CI `pest --parallel` (файл=процесс) проходят. **Lefthook в worktree-shell не в PATH** → cspell/larastan/squawk/deptrac не гонялись на коммитах; deptrac проверен инспекцией (Service→Service разрешён), новые cspell-термины (Rossvyaz/DaData/kopecks) добавлены в `cspell-words.txt`, остальное — CI на push. **§0 cross-refs НЕ меняются** — app-фича (сервисы/джоба/миграция), не tooling-канон #1-#86 / не ADR / не off-phase. Через `claude-md-management:revise-claude-md`.
**2026-05-31 (продолжение) router-gate v4 Layer 4 LLM-judge — item 2b live wiring + активация владельцем + readonly-калибровка:** `tools/enforce-llm-judge-per-tool.mjs` (PreToolUse) и `tools/enforce-llm-judge-response-scan.mjs` (Stop) получили живой `main()` (TDD, чистые `runPerTool`/`runResponseScan`; commit `dfae9f76`). Spend строго гейтится `resolveJudgeConfig()` (флаг `ROUTER_LLM_JUDGE_ENABLED` И ключ); без флага/ключа `decide()` короткозамыкается → $0. **Архитектурный нюанс:** регистрировать надо именно **обёртки** `enforce-llm-judge-*`, не движки `llm-judge-{per-tool,response-scan}.mjs`у движков `main()` зовёт `llmJudgeCall` по наличию ОДНОГО ключа, игнорируя флаг (т.е. движок начал бы тратить деньги без рубильника). **Владелец активировал Layer 4** (env `ROUTER_LLM_JUDGE_ENABLED=1` через `rundll32 sysdm.cpl,EditEnvironmentVariables` + ключ `ROUTER_LLM_KEY` уже был в user env, как у классификатора + регистрация обоих хуков в `.claude/settings.json` + перезапуск) → судья ожил в **hard-block** (подтверждено: тот же `git log`, что при выключенном флаге проходил мгновенно, после активации заблокирован реальным вызовом — verdict ≠ YES → block). **Операционная находка / over-block:** `MUTATING_TOOLS` в `llm-judge-per-tool.mjs` включает `Bash` целиком, а правило вопроса — «Сомнения → NO» + код «не-YES → block», поэтому живой судья блокировал даже readonly-просмотры (`git status`/`git log`/`grep`) — и тем самым полностью клинил рабочий цикл (commit/push/правки тоже под судьёй). **Калибровка** (commit `c9b9efd6`, TDD, судья на время выключался владельцем — правка кода тоже под судьёй): новый экспортируемый `isReadonlyBashEvent(event)` — если tool=Bash и `classifyBashCommand(command, {})` даёт `result==='allow'` с reason `readonly|reading`, `runPerTool` возвращает allow **до** обращения к судье (без LLM-вызова, без budget-bump). Это **scope-fix к собственной декларации судьи** («judge on mutating tools»), а **не понижение дисциплины**: правило doubt→block и полная проверка всего, что реально меняет состояние (Edit/Write/MultiEdit/git commit/push/Skill/Task), — без изменений. Регрессия vitest tools-only **1927 GREEN** (+13 калибровочных тестов; verify через `npx vitest run --root app --config vitest.config.tools.mjs`, т.к. `npm run test:tools` падает из-за параллельной keytar-установки в `app/node_modules`). Коммиты `dfae9f76` (live wiring) + `c9b9efd6` (калибровка); push `a8996896..c9b9efd6 main`. План `docs/superpowers/plans/2026-05-31-llm-judge-live-wiring.md`. **§0 cross-refs НЕ меняются** — инфраструктура `tools/enforce-*.mjs`, не tooling-канон #1-#86 / не ADR / не off-phase. Через `claude-md-management:revise-claude-md`.
+16985
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
.env
.env.backup
.env.production
.env.testing
.phpactor.json
.phpunit.result.cache
/.deptrac.cache
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
* у которых city ещё пуст, по resolved_subject_code связанного лида
* (deals supplier_lead_deliveries supplier_leads). Идемпотентно (только city IS NULL).
*
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
*/
final class DealsBackfillRegionCityCommand extends Command
{
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
$conn = DB::connection('pgsql_supplier');
$map = RussianRegions::CODE_TO_NAME;
$rows = $conn->table('deals')
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
->whereNull('deals.city')
->whereNotNull('sl.resolved_subject_code')
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
->get();
$seen = [];
$updated = 0;
foreach ($rows as $r) {
$dealId = (int) $r->id;
if (isset($seen[$dealId])) {
continue; // у сделки несколько доставок — обрабатываем один раз
}
$seen[$dealId] = true;
$name = $map[(int) $r->resolved_subject_code] ?? null;
if ($name === null) {
continue; // код вне справочника 1..89 — пропускаем
}
if (! $dryRun) {
$conn->table('deals')
->where('id', $dealId)
->where('received_at', $r->received_at) // partition key
->whereNull('city') // идемпотентный страж
->update(['city' => $name]);
}
$updated++;
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
Log::info('deals.backfill_region_city', [
'updated' => $updated,
'candidates' => count($seen),
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}
@@ -0,0 +1,142 @@
<?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');
}
}
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
/**
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
*
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
* php artisan phone-ranges:import --dir=<dir с пакетом файлов> [...]
*
* Алгоритм:
* 1. Резолв входных файлов (--file | --dir; --url отложен оператор качает пакет вручную).
* 2. Checksum-идемпотентность: совпал с предыдущим `completed` status='rolled_back', выход.
* 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) нормализованные строки.
* 4. Маппинг region subject_code через RussianRegions::nameToCode(). Несматчившиеся лог в error.
* 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
* 6. --dry-run staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
* Иначе atomic RENAME swap + status='completed'.
*
* Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker член crm_migrator,
* INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test postgres superuser).
*
* NB (swap operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом
* RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
* импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
*/
class PhoneRangesImportCommand extends Command
{
/** @var string */
protected $signature = 'phone-ranges:import
{--file= : Путь к одному CSV/XLSX файлу реестра}
{--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
{--url= : (отложено) URL пакета скачать вручную и использовать --dir}
{--force : Игнорировать checksum-идемпотентность}
{--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
/** @var string */
protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
/** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
private const DDL_CONNECTION = 'pgsql_supplier';
/** Размер пачки для bulk INSERT в staging. */
private const INSERT_CHUNK = 1000;
public function handle(): int
{
$files = $this->resolveFiles();
if ($files === null) {
return self::FAILURE;
}
$checksum = $this->computeChecksum($files);
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
// 2. Идемпотентность по checksum (если не --force).
if (! $force) {
$prev = DB::table('phone_ranges_imports')
->where('checksum_sha256', $checksum)
->where('status', 'completed')
->orderByDesc('id')
->first();
if ($prev !== null) {
DB::table('phone_ranges_imports')->insert([
'source_url' => $this->sourceLabel($files),
'checksum_sha256' => $checksum,
'status' => 'rolled_back',
'rows_inserted' => 0,
'rows_updated' => 0,
'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
'imported_at' => now(),
'completed_at' => now(),
]);
$this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
return self::SUCCESS;
}
}
// 3. Журнал импорта (in_progress).
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => $this->sourceLabel($files),
'checksum_sha256' => $checksum,
'status' => 'in_progress',
'imported_at' => now(),
]);
try {
// 4. Парсинг + маппинг.
$unmatched = [];
$rows = [];
foreach ($files as $file) {
foreach ($this->parseFile($file) as $rec) {
$subjectCode = RussianRegions::nameToCode()[trim($rec['region'])] ?? null;
if ($subjectCode === null && trim($rec['region']) !== '') {
$unmatched[trim($rec['region'])] = true;
}
$rows[] = [
'def_code' => $rec['def_code'],
'from_num' => $rec['from_num'],
'to_num' => $rec['to_num'],
'operator' => $rec['operator'],
'region' => $rec['region'],
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
];
}
}
// 5. Сборка staging.
$this->buildStaging($rows);
$unmatchedNote = $unmatched === []
? ''
: 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
if ($dryRun) {
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'rolled_back',
'rows_inserted' => count($rows),
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
'completed_at' => now(),
]);
$this->info('dry-run: '.count($rows)." строк в phone_ranges_staging, swap не выполнен.");
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
return self::SUCCESS;
}
// 6. Atomic swap (operator-validated — см. docblock).
$this->atomicSwap();
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'completed',
'rows_inserted' => count($rows),
'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
'completed_at' => now(),
]);
$this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
return self::SUCCESS;
} catch (\Throwable $e) {
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'failed',
'error' => mb_substr($e->getMessage(), 0, 2000),
'completed_at' => now(),
]);
$this->error('Импорт упал: '.$e->getMessage());
return self::FAILURE;
}
}
/**
* @return list<string>|null Список файлов или null при ошибке валидации опций.
*/
private function resolveFiles(): ?array
{
$file = $this->option('file');
$dir = $this->option('dir');
$url = $this->option('url');
if ($url !== null) {
$this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
return null;
}
if ($file !== null) {
if (! is_file($file)) {
$this->error("Файл не найден: {$file}");
return null;
}
return [$file];
}
if ($dir !== null) {
if (! is_dir($dir)) {
$this->error("Каталог не найден: {$dir}");
return null;
}
$found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
if ($found === []) {
$this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
return null;
}
sort($found);
return array_values($found);
}
$this->error('Укажите --file=<путь> или --dir=<каталог>.');
return null;
}
/**
* @param list<string> $files
*/
private function computeChecksum(array $files): string
{
if (count($files) === 1) {
return (string) hash_file('sha256', $files[0]);
}
$hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
sort($hashes);
return hash('sha256', implode('|', $hashes));
}
/**
* @param list<string> $files
*/
private function sourceLabel(array $files): string
{
return $this->option('url')
?? $this->option('dir')
?? ($files[0] ?? 'unknown');
}
/**
* Парсит один файл реестра в нормализованные строки.
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseFile(string $path): array
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $ext === 'xlsx'
? $this->parseXlsx($path)
: $this->parseCsv($path);
}
/**
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseCsv(string $path): array
{
$content = (string) file_get_contents($path);
// BOM strip + split строк (CRLF/CR/LF).
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
$lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
if ($lines === []) {
return [];
}
$header = str_getcsv((string) array_shift($lines), ';');
$cols = $this->resolveColumns($header);
$out = [];
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$cells = str_getcsv($line, ';');
$rec = $this->mapCells($cells, $cols);
if ($rec !== null) {
$out[] = $rec;
}
}
return $out;
}
/**
* Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseXlsx(string $path): array
{
$reader = new XlsxReader();
$reader->open($path);
$out = [];
$cols = null;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
if ($cols === null) {
$cols = $this->resolveColumns($cells);
continue;
}
$rec = $this->mapCells($cells, $cols);
if ($rec !== null) {
$out[] = $rec;
}
}
break; // только первый лист
}
$reader->close();
return $out;
}
/**
* Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
*
* @param list<string> $header
* @return array{def:int, from:int, to:int, operator:int, region:int}
*/
private function resolveColumns(array $header): array
{
$cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
foreach ($header as $i => $cell) {
$n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
if (str_contains($n, 'def') || str_contains($n, 'авс')) {
$cols['def'] = $i;
} elseif ($n === 'от') {
$cols['from'] = $i;
} elseif ($n === 'до') {
$cols['to'] = $i;
} elseif (str_contains($n, 'оператор')) {
$cols['operator'] = $i;
} elseif (str_contains($n, 'регион')) {
$cols['region'] = $i;
}
}
return $cols;
}
/**
* @param list<string> $cells
* @param array{def:int, from:int, to:int, operator:int, region:int} $cols
* @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
*/
private function mapCells(array $cells, array $cols): ?array
{
$def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
if ($def === 0) {
return null; // пустая/битая строка
}
return [
'def_code' => $def,
'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
'region' => trim((string) ($cells[$cols['region']] ?? '')),
];
}
/**
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
*
* @param list<array<string, mixed>> $rows
*/
private function buildStaging(array $rows): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
$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 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) {
$c->table('phone_ranges_staging')->insert($chunk);
}
}
/**
* Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
*
* NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
* Проверяется первым реальным импортом оператора (Session 6 runbook).
* Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
* GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
* таблице носит имя idx_phone_ranges_staging_lookup (косметика имя занято _old).
*/
private function atomicSwap(): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
// Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
// DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
// переименованиями не оставит phone_ranges несуществующей — откат вернёт
// живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
// Россвязь-lookup падал бы до ручного восстановления).
$c->transaction(function () use ($c) {
$c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
$c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
$c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
$c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
});
}
/**
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
* отсутствует RESET и работаем как superuser (зеркало миграционного паттерна).
*/
private function elevate(\Illuminate\Database\Connection $c): void
{
try {
$c->statement('SET ROLE crm_migrator');
$canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (! $canCreate || ! $canCreate->ok) {
$c->statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
/**
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
* DaData Россвязь tag и печатает решение. В БД ничего НЕ пишет.
*
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
*
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
* DADATA_API_KEY делает платный вызов запускать осознанно.
*/
class PhoneRegionSmokeCommand extends Command
{
/** @var string */
protected $signature = 'phone-region:smoke
{--phone= : Телефон в формате 7XXXXXXXXXX}
{--tag= : Регион-тег поставщика (fallback-слой)}';
/** @var string */
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
public function handle(LeadRegionResolver $resolver): int
{
$phone = (string) $this->option('phone');
if ($phone === '') {
$this->error('Укажите --phone=7XXXXXXXXXX');
return self::FAILURE;
}
// Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
config(['services.dadata.enabled' => true]);
$lead = new SupplierLead([
'phone' => $phone,
'raw_payload' => ['tag' => (string) $this->option('tag')],
]);
$r = $resolver->resolve($lead);
$region = $r->subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
: '—';
$this->info('Телефон: '.$this->maskPhone($phone));
$this->line('Источник: '.$r->source);
$this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
$this->line('Оператор: '.($r->phoneOperator ?? '—'));
$this->line('DaData qc: '.($r->qc ?? '—'));
$this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
$this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
$this->line('Длит., мс: '.($r->durationMs ?? '—'));
$this->newLine();
$this->comment('NB: запись в БД НЕ выполнялась (smoke).');
return self::SUCCESS;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}
+139 -9
View File
@@ -11,18 +11,22 @@ use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\Dto\RegionResolution;
use App\Services\LeadDistributor;
use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use App\Support\RussianRegions;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -128,7 +132,6 @@ class RouteSupplierLeadJob implements ShouldQueue
// Capture original error BEFORE update — $lead->update() mutates
// the in-memory model, so $lead->error after update() returns the
// suffixed value, breaking debug logs (review fix).
// быстрый коммит
$originalError = $lead->error;
$lead->update([
'processed_at' => now(),
@@ -148,16 +151,27 @@ class RouteSupplierLeadJob implements ShouldQueue
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
$lead->update(['supplier_project_id' => $supplier->id]);
$matched = $router->matchEligibleProjects($supplier);
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
// Lead region resolution (§3.11): резолв региона ДО routing-цикла, чтобы HTTP-вызов
// DaData (~150мс) не висел внутри tenant-транзакции. Резолвер — из контейнера (не 7-й
// параметр handle(), чтобы не ломать сигнатуру и существующие вызовы тестов).
// RegionTagResolver остаётся в DI-цепочке резолвера (fallback-слой).
$resolution = app(LeadRegionResolver::class)->resolve($lead);
$lead->update([
'resolved_subject_code' => $resolution->subjectCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'phone_operator' => $resolution->phoneOperator,
]);
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
// Каскад по региону (§3.9): exact → all-RF → fallback. NULL subject_code → шаг 1 пропуск.
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
$selected = $distributor->selectRecipients($matched);
$createdCount = 0;
$failures = [];
foreach ($selected as $project) {
try {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $resolution)) {
$createdCount++;
}
} catch (Throwable $e) {
@@ -178,6 +192,10 @@ class RouteSupplierLeadJob implements ShouldQueue
);
}
// Аудит резолва региона — одна строка на лид (§3.10/§7.1). Fail-safe: сбой записи
// аудит-лога НЕ должен ронять доставку лида (revenue-critical, 30k/сутки).
$this->logRegionResolution($lead, $resolution, $selected);
$lead->update([
'processed_at' => now(),
'deals_created_count' => $createdCount,
@@ -240,10 +258,14 @@ class RouteSupplierLeadJob implements ShouldQueue
Project $project,
NotificationService $notifier,
LedgerService $ledger,
?int $subjectCode,
RegionResolution $resolution,
): bool {
// routing_step проставлен LeadRouter'ом на matched-проекте; захватываем ДО
// переназначения $project = $lockedProject (fresh query без этого атрибута).
$routingStep = (int) ($project->routing_step ?? 1);
try {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $resolution, $routingStep): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -354,10 +376,21 @@ class RouteSupplierLeadJob implements ShouldQueue
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
// CSV-recovered received_at сохраняем как есть — отличие на минуты
// несущественно, чем риск каскадного DELETE lead_charges.
// §3.12: при merge обновляем регион/оператора, если webhook-резолв из
// источника выше рангом (dadata/rossvyaz), чем tag CSV-восстановления.
// deals не хранит region_source (он на supplier_leads + в журнале), поэтому
// ранг определяем по факту источника: dadata/rossvyaz всегда достовернее
// tag'а, на котором строилась CSV-recovery (RegionResolution::SOURCE_RANK).
$mergeUpdate = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
$mergeUpdate['subject_code'] = $resolution->subjectCode;
$mergeUpdate['phone_operator'] = $resolution->phoneOperator;
$mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
}
DB::table('deals')
->where('id', $existingMergeable->id)
->where('received_at', $existingMergeable->received_at)
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
->update($mergeUpdate);
Log::info('supplier_lead.merged_into_csv_recovered', [
'supplier_lead_id' => $lead->id,
@@ -394,6 +427,13 @@ class RouteSupplierLeadJob implements ShouldQueue
? array_values(array_map('strval', $payload['phones']))
: [(string) $lead->phone];
// §3.10: на шаге 3 (запасной канал) регион сделки подменяется на регион
// клиента (первый подписанный субъект из snapshot); настоящий регион —
// в lead_region_resolution_log.actual_subject_code. region_substituted флажит подмену.
$dealSubjectCode = $routingStep < 3
? $resolution->subjectCode
: ($this->pickSubstituteRegion((string) ($snapshot->regions ?? '{}')) ?? $resolution->subjectCode);
$deal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => $lead->vid,
@@ -402,7 +442,14 @@ class RouteSupplierLeadJob implements ShouldQueue
'phones' => $phones,
'status' => 'new',
'received_at' => $receivedAt,
'subject_code' => $subjectCode,
'subject_code' => $dealSubjectCode,
// «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
// по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
'city' => $resolution->subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
: null,
'phone_operator' => $resolution->phoneOperator,
'region_substituted' => $routingStep === 3,
]);
DB::table('supplier_lead_deliveries')
@@ -500,6 +547,89 @@ class RouteSupplierLeadJob implements ShouldQueue
]);
}
/**
* Аудит резолва региона лида одна строка на лид в lead_region_resolution_log (§7.1).
* Fail-safe: сбой записи (например, отсутствие партиции received_at) логируется warning'ом,
* но НЕ прерывает доставку (revenue-critical). INSERT через pgsql_supplier (GRANT INSERT
* у crm_supplier_worker). Телефон маскируется до INSERT сырой номер в лог не пишется.
*
* @param Collection<int, Project> $selected
*/
private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void
{
try {
$first = $selected->first();
$routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null;
$substituted = ($routingStep === 3 && $first !== null)
? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode)
: null;
$tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? ''));
DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([
'supplier_lead_id' => $lead->id,
'received_at' => $lead->received_at ?? now(),
'phone_masked' => $this->maskPhone((string) $lead->phone),
'subject_code_resolved' => $resolution->subjectCode,
'subject_code_from_tag' => $tagCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'dadata_provider' => $resolution->phoneOperator,
'dadata_type' => null,
'dadata_response_masked' => $resolution->dadataResponseMasked !== null
? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE)
: null,
'rossvyaz_matched' => $resolution->rossvyazMatched,
'actual_subject_code' => $resolution->actualSubjectCode,
'substituted_subject_code' => $substituted,
'routing_step' => $routingStep,
'phone_operator' => $resolution->phoneOperator,
'cache_hit' => $resolution->cacheHit,
'duration_ms' => $resolution->durationMs,
]);
} catch (Throwable $e) {
Log::warning('lead_region_resolution.log_write_failed', [
'supplier_lead_id' => $lead->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* Первый код субъекта из PG INT[]-литерала ('{82,83}' 82; '{}' null) регион клиента
* для подмены на запасном канале (§3.10).
*/
private function pickSubstituteRegion(string $regionsLiteral): ?int
{
return $this->parseSubjectCodes($regionsLiteral)[0] ?? null;
}
/**
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
*/
private function parseSubjectCodes(string $regionsLiteral): array
{
$inner = trim($regionsLiteral, '{}');
if ($inner === '') {
return [];
}
return array_values(array_map('intval', explode(',', $inner)));
}
/**
* Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567).
*/
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
/**
* Финальный callback после исчерпания всех ретраев ($tries=3).
*
+4
View File
@@ -61,6 +61,9 @@ class Deal extends Model
'is_test',
'received_at',
'deleted_at',
// Lead region resolution (Session 1, 31.05.2026).
'phone_operator',
'region_substituted',
];
protected function casts(): array
@@ -77,6 +80,7 @@ class Deal extends Model
'lead_score' => 'decimal:2',
'phones' => 'array',
'is_test' => 'boolean',
'region_substituted' => 'boolean',
'assigned_at' => 'datetime',
'received_at' => 'datetime',
'created_at' => 'datetime',
+7
View File
@@ -41,6 +41,11 @@ class SupplierLead extends Model
'recovered_from_csv_at',
'deals_created_count',
'error',
// Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display.
'resolved_subject_code',
'region_source',
'dadata_qc',
'phone_operator',
];
protected function casts(): array
@@ -52,6 +57,8 @@ class SupplierLead extends Model
'recovered_from_csv_at' => 'datetime',
'vid' => 'integer',
'deals_created_count' => 'integer',
'resolved_subject_code' => 'integer',
'dadata_qc' => 'integer',
];
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Support\Facades\Cache;
/**
* Дневной бюджет на платные вызовы DaData (spec §5.3 / §11).
*
* Расход копится в копейках под дневным ключом `phone_resolution:dadata:spent_kopecks:<YYYY-MM-DD>`.
* `Cache::increment` на redis-сторе атомарен (INCRBY) корректно при параллельных
* RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые.
*
* При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3).
*/
class DaDataBudgetGuard
{
public function canSpend(): bool
{
$capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100;
return $this->spentTodayKopecks() < $capKopecks;
}
public function recordSpend(int $kopecks): void
{
if ($kopecks <= 0) {
return;
}
$key = $this->dailyKey();
Cache::add($key, 0, now()->addDays(2));
Cache::increment($key, $kopecks);
}
public function spentTodayKopecks(): int
{
return (int) Cache::get($this->dailyKey(), 0);
}
private function dailyKey(): string
{
return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d');
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use RuntimeException;
/**
* Не-2xx ответ DaData (после исчерпания retry) или иная ошибка вызова.
* LeadRegionResolver ловит её и деградирует на Россвязь (spec §3.3).
*/
class DaDataException extends RuntimeException {}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* HTTP-обёртка над DaData clean/phone (spec §3.6).
*
* POST https://cleaner.dadata.ru/api/v1/clean/phone
* Authorization: Token <key> ; X-Secret: <secret> ; body ["<phone>"]
*
* Retry только на сетевые ошибки и 5xx (4xx сразу DaDataException, без retry).
* Сеть/таймаут после исчерпания retry DaDataTimeoutException; 5xx DaDataException.
* Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory).
*/
class DaDataPhoneClient
{
private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone';
public function __construct(
private readonly HttpFactory $http,
) {}
public function cleanPhone(string $phone): DaDataPhoneResponse
{
$cfg = (array) config('services.dadata');
$timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));
$attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1);
$apiKey = (string) ($cfg['api_key'] ?? '');
$secret = (string) ($cfg['secret'] ?? '');
$lastException = null;
for ($attempt = 0; $attempt < $attempts; $attempt++) {
try {
$response = $this->http
->asJson()
->acceptJson()
->timeout($timeoutSec)
->withHeaders([
'Authorization' => 'Token '.$apiKey,
'X-Secret' => $secret,
])
->post(self::URL, [$phone]);
} catch (ConnectionException $e) {
$lastException = new DaDataTimeoutException(
'DaData connection failed: '.$e->getMessage(), 0, $e,
);
continue; // сеть → retry
}
if ($response->serverError()) {
$lastException = new DaDataException('DaData 5xx: HTTP '.$response->status());
continue; // 5xx → retry
}
if (! $response->successful()) {
// 4xx — клиентская ошибка, retry бессмыслен.
throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body());
}
return $this->parse($response->json());
}
throw $lastException ?? new DaDataException('DaData failed without a response');
}
/**
* @param mixed $body декодированный JSON (ожидается массив с одним объектом)
*/
private function parse($body): DaDataPhoneResponse
{
$row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : [];
return new DaDataPhoneResponse(
qc: isset($row['qc']) ? (int) $row['qc'] : null,
qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null,
type: isset($row['type']) ? (string) $row['type'] : null,
phone: isset($row['phone']) ? (string) $row['phone'] : null,
provider: isset($row['provider']) ? (string) $row['provider'] : null,
region: isset($row['region']) ? (string) $row['region'] : null,
city: isset($row['city']) ? (string) $row['city'] : null,
timezone: isset($row['timezone']) ? (string) $row['timezone'] : null,
raw: $row,
);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Распарсенный ответ DaData clean/phone (один номер один объект), spec §3.6.
*/
final class DaDataPhoneResponse
{
/**
* @param array<string, mixed> $raw полный сырой объект ответа (для маскированного лога)
*/
public function __construct(
public readonly ?int $qc,
public readonly ?int $qcConflict,
public readonly ?string $type,
public readonly ?string $phone,
public readonly ?string $provider,
public readonly ?string $region,
public readonly ?string $city,
public readonly ?string $timezone,
public readonly array $raw,
) {}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Код качества (`qc`) ответа DaData clean/phone.
*
* Семантика DaData:
* 0 телефон распознан уверенно;
* 1 распознан с допущениями (требует проверки);
* 2 пустой / невозможно распознать;
* 3 несколько телефонов в одном поле;
* 7 иностранный номер.
*
* Решения каскада по qc в LeadRegionResolver (spec §3.4). Enum используется
* для читаемости и tryFrom() при парсинге; необъявленные значения остаются как int.
*/
enum DaDataQualityCode: int
{
case RECOGNIZED = 0;
case ASSUMPTIONS = 1;
case EMPTY = 2;
case MULTIPLE = 3;
case FOREIGN = 7;
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Сетевая ошибка / таймаут DaData (после исчерпания retry на сетевые сбои).
* Подкласс DaDataException catch(DaDataException) покрывает оба случая.
*/
class DaDataTimeoutException extends DaDataException {}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
use App\Models\SupplierLead;
/**
* Результат резолва региона лида (LeadRegionResolver, spec §3.3).
*
* `subjectCode` итоговый код субъекта (используется маршрутизатором);
* `actualSubjectCode` настоящий резолв (для лога actual_subject_code; на этапе
* резолва равен subjectCode, подмена региона концерн RouteSupplierLeadJob §3.10).
* `source` dadata|rossvyaz|tag|unknown ранг см. SOURCE_RANK (CSV-merge §3.12).
*/
final readonly class RegionResolution
{
/** @var array<string, int> ранг источника для CSV-merge (выше = достовернее) */
public const SOURCE_RANK = [
'dadata' => 4,
'rossvyaz' => 3,
'tag' => 2,
'unknown' => 1,
];
/**
* @param array<string, mixed>|null $dadataResponseMasked
*/
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,
) {}
/**
* @param array<string, mixed>|null $dadataMasked
*/
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 {
return new self(
subjectCode: $subjectCode,
actualSubjectCode: $subjectCode,
source: $source,
phoneOperator: $operator,
qc: $qc,
cacheHit: $cacheHit,
dadataResponseMasked: $dadataMasked,
durationMs: $durationMs,
rossvyazMatched: $rossvyazMatched,
);
}
public static function fromTag(?int $subjectCode): self
{
return self::make($subjectCode, $subjectCode !== null ? 'tag' : 'unknown');
}
/**
* Восстановление из persistent state лида (retry-идемпотентность §3.11) без DaData-вызова.
*/
public static function fromSupplierLead(SupplierLead $lead): self
{
return self::make(
subjectCode: $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
source: (string) ($lead->region_source ?? 'unknown'),
operator: $lead->phone_operator,
qc: $lead->dadata_qc !== null ? (int) $lead->dadata_qc : null,
);
}
public function withCacheHit(bool $hit): self
{
return new self(
subjectCode: $this->subjectCode,
actualSubjectCode: $this->actualSubjectCode,
source: $this->source,
phoneOperator: $this->phoneOperator,
qc: $this->qc,
cacheHit: $hit,
dadataResponseMasked: null, // §3.11: cache-hit лог не несёт masked-ответ
durationMs: $this->durationMs,
rossvyazMatched: $this->rossvyazMatched,
);
}
/**
* Версия для записи в кэш (§7.3): без per-call полей (masked-ответ, длительность, cache-флаг).
*/
public function forCache(): self
{
return new self(
subjectCode: $this->subjectCode,
actualSubjectCode: $this->actualSubjectCode,
source: $this->source,
phoneOperator: $this->phoneOperator,
qc: $this->qc,
cacheHit: false,
dadataResponseMasked: null,
durationMs: null,
rossvyazMatched: $this->rossvyazMatched,
);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
/**
* Read-only результат поиска по реестру нумерации Россвязи (`phone_ranges`).
*
* `subjectCode` код субъекта РФ 1..89 (см. App\Support\RussianRegions) либо
* null, если для диапазона он не был промаплен при импорте.
*/
final readonly class RossvyazRecord
{
public function __construct(
public ?int $subjectCode,
public string $region,
public string $operator,
) {}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\SupplierLead;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataPhoneResponse;
use App\Services\Dto\RegionResolution;
use App\Support\DaDataRegionMap;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Оркестратор резолва региона лида: DaData Россвязь tag-fallback (spec §3.3, §3.4).
*
* Каскад решений по qc:
* qc 0/3 + region не-ambiguous и маппится source=dadata;
* qc 0/3 + region ambiguous/null/не-маппится Россвязь (оператор от DaData сохраняем, §3.4.1);
* qc 1 / таймаут / 5xx / бюджет исчерпан Россвязь;
* qc 2/7 tag (Россвязь бессмысленна).
* Если ничего не дало код source=tag (или unknown при пустом теге).
*
* Кэш по sha256(phone) (без сырого номера в ключе/значении, §7.3). Persistent-idempotency
* по supplier_leads.resolved_subject_code (защита от двойной оплаты DaData на retry, §3.11).
* Feature-flag services.dadata.enabled=false сразу tag (текущее поведение, §6.5).
*/
class LeadRegionResolver
{
public function __construct(
private readonly DaDataPhoneClient $dadataClient,
private readonly DaDataBudgetGuard $budgetGuard,
private readonly RossvyazPrefixLookup $rossvyazLookup,
private readonly RegionTagResolver $tagResolver,
private readonly CacheRepository $cache,
) {}
public function resolve(SupplierLead $lead): RegionResolution
{
// Feature-flag: резолвер выключен → текущее tag-поведение.
if (! (bool) config('services.dadata.enabled', false)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
// Persistent-idempotency: уже резолвили на предыдущем try → без DaData.
if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
return RegionResolution::fromSupplierLead($lead);
}
$phone = (string) $lead->phone;
if (! preg_match('/^7\d{10}$/', $phone)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
$cacheKey = $this->cacheKey($phone);
$cached = $this->cache->get($cacheKey);
if ($cached instanceof RegionResolution) {
return $cached->withCacheHit(true);
}
$resolution = $this->doResolve($lead, $phone);
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
$this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays));
return $resolution;
}
private function doResolve(SupplierLead $lead, string $phone): RegionResolution
{
$start = microtime(true);
$provider = null;
$qc = null;
$masked = null;
// 1. DaData (под дневным бюджетом).
if ($this->budgetGuard->canSpend()) {
try {
$dadata = $this->dadataClient->cleanPhone($phone);
$this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
$qc = $dadata->qc;
$provider = $dadata->provider;
$masked = $this->maskResponse($dadata);
if (in_array($dadata->qc, [0, 3], true)) {
$region = (string) ($dadata->region ?? '');
if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) {
$code = DaDataRegionMap::toSubjectCode($region);
if ($code !== null) {
return RegionResolution::make(
$code, 'dadata',
operator: $provider, qc: $qc,
dadataMasked: $masked, durationMs: $this->ms($start),
);
}
// qc=0/3, но регион не маппится → страховка Россвязью.
}
// ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем).
} elseif ($dadata->qc === 2 || $dadata->qc === 7) {
// Мусор / иностранец → Россвязь не поможет, сразу tag.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
// qc=1 → Россвязь.
} catch (DaDataException) {
// Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем.
}
}
// 2. Россвязь.
$rossvyaz = $this->rossvyazLookup->find($phone);
if ($rossvyaz !== null) {
$code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region);
if ($code !== null) {
return RegionResolution::make(
$code, 'rossvyaz',
operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP)
qc: $qc, dadataMasked: $masked,
durationMs: $this->ms($start), rossvyazMatched: true,
);
}
}
// 3. Tag-fallback.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution
{
$tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : '');
$tagCode = $this->tagResolver->resolve($tag);
return RegionResolution::make(
$tagCode,
$tagCode !== null ? 'tag' : 'unknown',
operator: $provider,
qc: $qc,
dadataMasked: $masked,
durationMs: $start !== null ? $this->ms($start) : null,
);
}
private function cacheKey(string $phone): string
{
return 'phone-region:'.hash('sha256', $phone);
}
private function ms(float $start): int
{
return (int) round((microtime(true) - $start) * 1000);
}
/**
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
*/
private function maskResponse(DaDataPhoneResponse $response): array
{
$raw = $response->raw;
if (isset($raw['phone']) && is_string($raw['phone'])) {
$raw['phone'] = $this->maskPhone($raw['phone']);
}
return $raw;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}
+171 -81
View File
@@ -10,129 +10,219 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Random\Randomizer;
/**
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
* каскадной маршрутизацией по региону (lead region resolution §3.9).
*
* Eligibility структурно через snapshot `project_routing_snapshots` за активную
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
* с 21:00 МСК завтрашней. Все эффективные параметры маршрутизации
* (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.)
* берутся из snapshot. Из live `projects` только `delivered_today` (счётчик
* остатка лимита, обновляется в течение дня) и из `tenants` `balance_rub`
* (live auto-pause при нулевом балансе).
* с 21:00 МСК завтрашней. Все эффективные параметры маршрутизации берутся из
* snapshot; из live `projects` только `delivered_today` (остаток лимита),
* из `tenants` `balance_rub` + `frozen_by_balance_at` (live auto-pause).
*
* Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) клиент Лидерры,
* который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно
* получает свои оплаченные лиды по уже зафиксированному slepok'у.
* Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
* 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
* 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
* 3) запасной канал (без фильтра региона) только если первые две пусты;
* сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
* Каждый Project помечается атрибутом `routing_step` (1/2/3).
*
* Регион сопоставляется самим supplier_project (тег = субъект) phone-prefix
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
* гарантирован тем, через какой supplier_project пришёл лид.
* Отбор внутри фазы при кандидатах > cap **взвешенный жребий по остатку лимита**
* (вариант D1=В): шанс остатку, но у каждого кандидата шанс > 0 (вес 1)
* маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
* Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
*
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) в
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
*
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3.
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
* + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
*/
class LeadRouter
{
public function __construct(
private readonly Randomizer $randomizer = new Randomizer,
) {}
/**
* Возвращает ONE project per tenant_id тот, у которого наибольший остаток
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
*
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
* (клиентам), каждый тенант получает ровно ОДИН проект с наибольшим остатком.
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
* а не число проектов, потому что входные данные уже one-per-tenant.
*
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) tenant ещё не
* определён, SELECT видит проекты всех tenant'ов.
* Возвращает cap проектов (по одному на tenant), отобранных каскадом
* по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
*
* @return Collection<int, Project>
*/
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
{
// Активная дата слепка вычисляется в PHP — детерминирована для всего запроса,
// тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами.
$activeDate = $this->activeSnapshotDate();
$cap = LeadDistributor::CAP;
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
// match с Лидерра-проектами через snapshot (project_supplier_links для
// DIRECT-row'ов не создаются — DIRECT supplier_projects создаются автоматически
// при получении webhook'а без B-префикса).
if ($supplierProject->platform === 'DIRECT') {
$directSql = <<<'SQL'
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND snap.signal_type = ?
AND LOWER(snap.signal_identifier) = LOWER(?)
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
$directRows = DB::connection('pgsql_supplier')->select(
$directSql,
[$activeDate, $supplierProject->signal_type, $supplierProject->unique_key]
);
// Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
$exact = $resolvedSubjectCode !== null
? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
: collect();
$selected = $this->weightedPick($exact, $cap);
$this->tagStep($selected, 1);
$this->logIfNoSnapshot($directRows, $supplierProject, $activeDate);
return Project::hydrate($directRows)->values();
if ($selected->count() >= $cap) {
return $selected->take($cap)->values();
}
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
$sql = <<<'SQL'
// Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
$allRu = $this->queryCandidates(
$activeDate, $supplierProject, 'all_ru', null,
$selected->pluck('tenant_id')->all(),
);
$pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
$this->tagStep($pickedRu, 2);
$combined = $selected->concat($pickedRu);
if ($combined->isNotEmpty()) {
return $combined->take($cap)->values();
}
// Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
$fallback = $this->weightedPick(
$this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
$cap,
);
$this->tagStep($fallback, 3);
$this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);
return $fallback->take($cap)->values();
}
/**
* Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
* regionFilter exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
* упорядоченных по остатку лимита DESC, created_at, id; жребий поверх в PHP.
*
* @param list<int> $excludeTenantIds
* @return Collection<int, Project>
*/
private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
{
$bindings = [$activeDate];
if ($sp->platform === 'DIRECT') {
// DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier.
$sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
$bindings[] = $sp->signal_type;
$bindings[] = $sp->unique_key;
} else {
$sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
$bindings[] = $sp->id;
}
$regionWhere = '';
if ($regionFilter === 'exact') {
$regionWhere = 'AND ?::int = ANY(snap.regions)';
$bindings[] = $code;
} elseif ($regionFilter === 'all_ru') {
$regionWhere = "AND snap.regions = '{}'::int[]";
}
$excludeWhere = '';
if ($excludeTenantIds !== []) {
$placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
$excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
foreach ($excludeTenantIds as $tid) {
$bindings[] = $tid;
}
}
$sql = <<<SQL
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit
snap.daily_limit AS snapshot_daily_limit,
snap.regions AS snapshot_regions
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id
AND psl.supplier_project_id = ?
)
AND $sourceWhere
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
$regionWhere
$excludeWhere
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
$rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]);
$this->logIfNoSnapshot($rows, $supplierProject, $activeDate);
return Project::hydrate($rows)->values();
return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
}
/**
* Активная дата слепка по правилу slepok-инварианта:
* до 21:00 МСК сегодняшняя дата;
* с 21:00 МСК завтрашняя.
* Взвешенный жребий без возврата (вариант D1=В): отбирает $n кандидатов,
* вероятность остатку лимита, вес 1 у каждого (мелкие не отрезаются).
* При кандидатах $n возвращает всех в исходном SQL-порядке (детерминизм).
*
* Spec §4.2.3.
* @param Collection<int, Project> $candidates
* @return Collection<int, Project>
*/
private function weightedPick(Collection $candidates, int $n): Collection
{
if ($n <= 0) {
return collect();
}
$pool = $candidates->values()->all();
if (count($pool) <= $n) {
return collect($pool);
}
$picked = [];
for ($i = 0; $i < $n && $pool !== []; $i++) {
$total = 0;
foreach ($pool as $p) {
$total += $this->weightOf($p);
}
$roll = $this->randomizer->getInt(1, $total);
$acc = 0;
$winner = 0;
foreach ($pool as $idx => $p) {
$acc += $this->weightOf($p);
if ($roll <= $acc) {
$winner = $idx;
break;
}
}
$picked[] = $pool[$winner];
array_splice($pool, $winner, 1);
}
return collect($picked);
}
private function weightOf(Project $project): int
{
$remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;
return max(1, $remaining);
}
/**
* @param Collection<int, Project> $projects
*/
private function tagStep(Collection $projects, int $step): void
{
foreach ($projects as $project) {
$project->setAttribute('routing_step', $step);
}
}
/**
* Активная дата слепка: до 21:00 МСК сегодня, с 21:00 МСК завтра (§4.2.3).
*/
private function activeSnapshotDate(): string
{
@@ -144,11 +234,11 @@ class LeadRouter
}
/**
* Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки
* snapshot'а это значит, что cron `SnapshotProjectRoutingJob` не отработал.
* (Если строки есть, но ни одна не сматчилась это валидный 0-результат, не алерт.)
* Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
* snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
* результат при наличии snapshot'ов не алерт.
*
* @param array<int, object> $rows
* @param array<int, mixed> $rows
*/
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
{
@@ -59,6 +59,8 @@ class MonthlyPartitionManager
'saas_admin_audit_log' => 'created_at',
// Slepok routing (Этап 2, 27.05.2026)
'project_routing_snapshots' => 'snapshot_date',
// Lead region resolution (Session 1, 31.05.2026)
'lead_region_resolution_log' => 'received_at',
];
/**
@@ -91,6 +93,22 @@ 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();
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Services\Dto\RossvyazRecord;
use Illuminate\Support\Facades\DB;
/**
* Локальный fallback резолва региона/оператора по телефону через реестр
* нумерации Россвязи (`phone_ranges`).
*
* Используется LeadRegionResolver когда DaData недоступна/неуверена (qc=1,
* timeout, бюджет исчерпан). Алгоритм (spec §3.7):
* - def_code = 3 цифры кода ABC/DEF (позиции 1..3 нормализованного номера);
* - subscriber = остаток номера как BIGINT;
* - выбираем самый УЗКИЙ диапазон, накрывающий номер (ORDER BY width ASC),
* т.к. узкие переопределения операторов точнее широких региональных блоков.
*
* Запрос идёт через `pgsql_supplier` (BYPASSRLS на проде, как LeadRouter):
* `phone_ranges` SaaS-level публичные данные без RLS.
*/
class RossvyazPrefixLookup
{
/** Connection для чтения реестра (на проде BYPASSRLS, на dev/test — superuser fallback). */
public const CONNECTION = 'pgsql_supplier';
public function find(string $phone): ?RossvyazRecord
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
// Российский номер: 7|8 + ABC/DEF (3) + абонент (7) = 11 цифр.
if (strlen($digits) !== 11) {
return null;
}
$defCode = (int) substr($digits, 1, 3);
$subscriber = (int) substr($digits, 4);
$row = DB::connection(self::CONNECTION)->selectOne(
'SELECT region, operator, subject_code
FROM phone_ranges
WHERE def_code = ? AND from_num <= ? AND to_num >= ?
ORDER BY (to_num - from_num) ASC
LIMIT 1',
[$defCode, $subscriber, $subscriber],
);
if ($row === null) {
return null;
}
return new RossvyazRecord(
subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null,
region: (string) $row->region,
operator: (string) $row->operator,
);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Маппинг строки региона из ответа DaData код субъекта РФ (1..89).
*
* DaData возвращает регион в поле `region` (например «Москва», «Московская область»).
* Большинство имён точно совпадают с App\Support\RussianRegions::CODE_TO_NAME;
* расхождения (если найдутся на staging) кладутся в OVERRIDES.
*
* «Объединённые» агломерации («Санкт-Петербург и область») DaData не различает
* город и область внутри поля region. Такие строки помечаются isAmbiguous()
* LeadRegionResolver уходит за точным subject_code в Россвязь (spec §3.4.1).
*/
final class DaDataRegionMap
{
/**
* Строки-агломерации, по которым нельзя однозначно определить субъект.
* Расширяется по реальным наблюдениям на staging (spec §3.4.1).
*
* @var list<string>
*/
public const AMBIGUOUS_REGIONS = [
'Санкт-Петербург и область',
'Москва и область',
];
/**
* Ручные переопределения для имён DaData, не совпадающих с RussianRegions.
* На старте пуст заполняется по findings со staging-smoke.
*
* @var array<string, int>
*/
public const OVERRIDES = [];
public static function toSubjectCode(string $name): ?int
{
$name = trim($name);
if ($name === '') {
return null;
}
return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null;
}
public static function isAmbiguous(string $name): bool
{
return in_array(trim($name), self::AMBIGUOUS_REGIONS, true);
}
}
+83
View File
@@ -0,0 +1,83 @@
# 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`.
+13
View File
@@ -42,4 +42,17 @@ return [
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
],
// DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
// Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки.
'dadata' => [
'api_key' => env('DADATA_API_KEY'),
'secret' => env('DADATA_SECRET'),
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
'retries' => (int) env('DADATA_RETRIES', 1),
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
],
];
@@ -18,6 +18,16 @@ use Illuminate\Support\Facades\DB;
*/
return new class extends Migration
{
/**
* Run OUTSIDE Laravel's migration transaction. This migration loads the full
* schema via DB::unprepared(), then calls partitions:create-months which opens
* a SECOND connection (pgsql_supplier) for partition DDL. That connection cannot
* see uncommitted DDL from this migration's transaction (PostgreSQL READ
* COMMITTED), so the schema must be committed first. This is a full schema
* (re)build no partial rollback is meaningful.
*/
public $withinTransaction = false;
public function up(): void
{
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
@@ -38,6 +38,8 @@ 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,10 +11,20 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
$table->index('paused_at', 'projects_paused_at_idx');
});
// 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');
});
}
// Backfill: для уже paused проектов используем updated_at как best-effort
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// SET ROLE crm_migrator на проде (postgres superuser может SET ROLE).
// На dev/testing crm_migrator не имеет GRANT на public schema → RESET ROLE
// и продолжаем как postgres superuser.
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
DB::unprepared(<<<'SQL'
-- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым)
CREATE TABLE phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE phone_ranges_imports IS
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
-- 2. phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS публичные данные)
CREATE TABLE phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.';
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log)
CREATE TABLE lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
phone_operator TEXT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
COMMENT ON TABLE lead_region_resolution_log IS
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).';
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
-- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2).
CREATE TABLE lead_region_resolution_log_y2026_m05
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- 4. supplier_leads: +4 колонки (denormalized display + persistent idempotency для retry).
ALTER TABLE supplier_leads
ADD COLUMN resolved_subject_code SMALLINT
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
ADD COLUMN region_source TEXT
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
ADD COLUMN dadata_qc SMALLINT,
ADD COLUMN phone_operator TEXT;
-- 5. deals: +2 колонки (UI-карточка + флаг подмены региона).
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
SQL);
// Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней).
$exists = DB::table('system_settings')
->where('key', 'partition_retention_months_lead_region_resolution_log')
->exists();
if (! $exists) {
DB::table('system_settings')->insert([
'key' => 'partition_retention_months_lead_region_resolution_log',
'value' => '12',
'type' => 'int',
'description' => 'Retention в месяцах для lead_region_resolution_log (~365 дней)',
'updated_at' => now(),
]);
}
}
public function down(): void
{
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
DB::unprepared(<<<'SQL'
ALTER TABLE deals
DROP COLUMN IF EXISTS phone_operator,
DROP COLUMN IF EXISTS region_substituted;
ALTER TABLE supplier_leads
DROP COLUMN IF EXISTS resolved_subject_code,
DROP COLUMN IF EXISTS region_source,
DROP COLUMN IF EXISTS dadata_qc,
DROP COLUMN IF EXISTS phone_operator;
DROP TABLE IF EXISTS lead_region_resolution_log CASCADE;
DROP TABLE IF EXISTS phone_ranges CASCADE;
DROP TABLE IF EXISTS phone_ranges_imports CASCADE;
SQL);
DB::table('system_settings')
->where('key', 'partition_retention_months_lead_region_resolution_log')
->delete();
}
};
+5 -439
View File
@@ -5,7 +5,6 @@
"packages": {
"": {
"dependencies": {
"keytar": "*",
"lucide-vue-next": "^1.0.0"
},
"devDependencies": {
@@ -40,9 +39,6 @@
"vue-tsc": "^3.2.8",
"vuedraggable": "^4.1.0",
"vuetify": "^3.12.5"
},
"optionalDependencies": {
"keytar": "^7.9.0"
}
},
"node_modules/@acemir/cssom": {
@@ -4226,27 +4222,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -4267,18 +4242,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4312,31 +4275,6 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -4443,13 +4381,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC",
"optional": true
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4721,32 +4652,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4828,7 +4733,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4953,16 +4858,6 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -5375,16 +5270,6 @@
"node": ">=0.10.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -5685,13 +5570,6 @@
"node": ">=18.3.0"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT",
"optional": true
},
"node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
@@ -5821,13 +5699,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT",
"optional": true
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -6296,27 +6167,6 @@
"node": ">= 14"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6344,18 +6194,11 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/is-docker": {
@@ -6717,25 +6560,6 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keytar/node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT",
"optional": true
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7466,19 +7290,6 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -7499,7 +7310,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7522,13 +7333,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT",
"optional": true
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -7582,13 +7386,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT",
"optional": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -7596,19 +7393,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -7670,16 +7454,6 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
@@ -8069,34 +7843,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8151,17 +7897,6 @@
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8203,47 +7938,6 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -8628,27 +8322,6 @@
"node": ">=6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/sass": {
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
@@ -9058,7 +8731,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9140,53 +8813,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -9307,16 +8933,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -9479,36 +9095,6 @@
"node": ">=16.0.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -9653,19 +9239,6 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -9882,7 +9455,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
@@ -10533,13 +10106,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
-3
View File
@@ -51,8 +51,5 @@
},
"dependencies": {
"lucide-vue-next": "^1.0.0"
},
"optionalDependencies": {
"keytar": "^7.9.0"
}
}
+2
View File
@@ -28,6 +28,8 @@
<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"/>
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/**
* Сеет сделку (city=NULL по умолчанию) + лид с resolved_subject_code + связь
* supplier_lead_deliveries. Возвращает [tenantId, dealId].
*
* @return array{0: int, 1: int}
*/
function seedDealWithResolvedLead(?int $resolvedCode, ?string $city = null): array
{
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'backfill-city.ru',
'is_active' => true,
]);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$deal = Deal::create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'phone' => '79161234567',
'phones' => ['79161234567'],
'status' => 'new',
'received_at' => now(),
'subject_code' => $resolvedCode,
'city' => $city,
]);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
$lead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => '79161234567',
'resolved_subject_code' => $resolvedCode,
'region_source' => $resolvedCode !== null ? 'dadata' : 'unknown',
]);
DB::connection('pgsql_supplier')->table('supplier_lead_deliveries')->insert([
'supplier_lead_id' => $lead->id,
'tenant_id' => $tenant->id,
'deal_id' => $deal->id,
'created_at' => now(),
]);
return [$tenant->id, $deal->id];
}
function dealCity(int $dealId): ?string
{
// BYPASSRLS чтение (как и сам бэкфилл) — без tenant-контекста.
return DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->value('city');
}
it('backfills deal city from the lead resolved region code', function (): void {
[, $dealId] = seedDealWithResolvedLead(29); // 29 → Красноярский край
$this->artisan('deals:backfill-region-city')->assertSuccessful();
expect(dealCity($dealId))->toBe('Красноярский край');
});
it('does not touch deals that already have a city', function (): void {
[, $dealId] = seedDealWithResolvedLead(29, city: 'Уже стоит');
$this->artisan('deals:backfill-region-city')->assertSuccessful();
expect(dealCity($dealId))->toBe('Уже стоит');
});
it('dry-run reports candidates without writing', function (): void {
[, $dealId] = seedDealWithResolvedLead(29);
$this->artisan('deals:backfill-region-city', ['--dry-run' => true])->assertSuccessful();
expect(dealCity($dealId))->toBeNull();
});
it('leaves city null when the lead has no resolved region', function (): void {
[, $dealId] = seedDealWithResolvedLead(null);
$this->artisan('deals:backfill-region-city')->assertSuccessful();
expect(dealCity($dealId))->toBeNull();
});
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function rossvyazFixture(): string
{
return base_path('tests/Fixtures/rossvyaz/sample.csv');
}
it('dry-run parses csv, maps regions to subject_code, builds staging, does not swap', function (): void {
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
// Staging построен (dry-run не свапает и не дропает staging — данные видны в той же tx).
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
$r495 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 495');
$r921 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 921');
$r999 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 999');
expect((int) $r495->subject_code)->toBe(82) // Москва
->and((int) $r921->subject_code)->toBe(83) // Санкт-Петербург
->and($r999->subject_code)->toBeNull(); // Атлантида — не маппится
// Живой phone_ranges не тронут (свапа не было).
expect(DB::table('phone_ranges')->count())->toBe(0);
// Журнал импорта: dry-run → rolled_back, несматчившийся регион в error.
$imp = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
expect($imp->status)->toBe('rolled_back')
->and($imp->error)->toContain('Атлантида');
});
it('maps all matched rows and counts unmatched separately', function (): void {
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
$matched = DB::table('phone_ranges_staging')->whereNotNull('subject_code')->count();
$unmatched = DB::table('phone_ranges_staging')->whereNull('subject_code')->count();
expect($matched)->toBe(2)->and($unmatched)->toBe(1);
});
it('skips swap when checksum matches a completed import (idempotency)', function (): void {
$checksum = hash_file('sha256', rossvyazFixture());
DB::table('phone_ranges_imports')->insert([
'source_url' => 'https://rossvyaz.gov.ru/prev',
'checksum_sha256' => $checksum,
'status' => 'completed',
'imported_at' => now(),
'completed_at' => now(),
]);
// Не dry-run: но checksum совпал с completed → короткое замыкание ДО свапа.
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture()])
->assertSuccessful();
expect(DB::table('phone_ranges')->count())->toBe(0); // свапа не было
$latest = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
expect($latest->status)->toBe('rolled_back');
});
it('force flag bypasses idempotency note even with matching checksum', function (): void {
// С --dry-run + --force: идемпотентность игнорируется, но dry-run всё равно не свапает.
$checksum = hash_file('sha256', rossvyazFixture());
DB::table('phone_ranges_imports')->insert([
'source_url' => 'https://rossvyaz.gov.ru/prev',
'checksum_sha256' => $checksum,
'status' => 'completed',
'imported_at' => now(),
'completed_at' => now(),
]);
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true, '--force' => true])
->assertSuccessful();
// --force обошёл idempotency → staging построен заново (3 строки), но dry-run не свапнул.
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
expect(DB::table('phone_ranges')->count())->toBe(0);
});
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
config([
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
});
it('phone-region:smoke prints the resolution and writes nothing to DB', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
$this->artisan('phone-region:smoke', ['--phone' => '79161234567'])
->assertSuccessful()
->expectsOutputToContain('dadata')
->expectsOutputToContain('Москва');
// Smoke не пишет в БД.
expect(DB::table('lead_region_resolution_log')->count())->toBe(0);
expect(DB::table('deals')->count())->toBe(0);
});
it('phone-region:smoke fails without --phone', function (): void {
$this->artisan('phone-region:smoke')->assertFailed();
});
@@ -0,0 +1,71 @@
<?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');
@@ -0,0 +1,37 @@
<?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');
@@ -0,0 +1,33 @@
<?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');
@@ -0,0 +1,87 @@
<?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');
@@ -0,0 +1,373 @@
<?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');
@@ -0,0 +1,282 @@
<?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');
@@ -0,0 +1,719 @@
<?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');
@@ -0,0 +1,265 @@
<?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');
@@ -0,0 +1,525 @@
<?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');
@@ -0,0 +1,291 @@
<?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');
@@ -0,0 +1,388 @@
<?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');
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
/**
* Verification tests ScenarioX1X3_SubstitutionJournalTest (Task 12, Phase 1 imitation).
*
* PROVING tests against existing production routing code.
* Differences vs plan FINDINGS captured in test output and comments.
* NO production code is modified. Only this one file is created.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md
* "Task 12: X1 — подмена региона на шаге 3 + журнал; X3 — сводка источника"
* Spec: §6.5 X1/X3 + §7 п.30/41
*
* ── KEY PRODUCTION CODE FACTS (verified from reading prod code, NOT guessing) ────
*
* RouteSupplierLeadJob::createDealCopyForProject() lines 433453:
* $dealSubjectCode = ($routingStep < 3)
* ? $resolution->subjectCode
* : (pickSubstituteRegion($snapshot->regions ?? '{}') ?? $resolution->subjectCode);
* Deal::create([
* 'subject_code' => $dealSubjectCode, // substituted (client's) on step 3
* 'city' => CODE_TO_NAME[$resolution->subjectCode] ?? null, // REAL lead region ALWAYS
* 'region_substituted' => ($routingStep === 3), // flag
* ]);
*
* RouteSupplierLeadJob::logRegionResolution() lines 558595:
* $substituted = ($routingStep === 3 && $first !== null)
* ? (pickSubstituteRegion($first->snapshot_regions ?? '{}') ?? $resolution->subjectCode)
* : null;
* INSERT lead_region_resolution_log {
* actual_subject_code => $resolution->actualSubjectCode // real resolved code
* substituted_subject_code=> $substituted // client's code on step 3, else null
* routing_step => $routingStep // step of FIRST selected project
* subject_code_resolved => $resolution->subjectCode // real resolved code (same as actual)
* }
*
* LeadRouter: phase 3 fires ONLY when combined(phase1+phase2) is EMPTY.
* To force step 3: the ONLY eligible client must have an exact region DIFFERENT
* from the lead's resolved region, AND no all-RF client (regions='{}').
* phase 1 empty (exact mismatch), phase 2 empty (no '{}' client), phase 3 fires.
*
* pickSubstituteRegion() picks the FIRST int from the PG INT[]-literal '{R_client}'.
* snapshot.regions column holds the client's subscribed regions.
*
* RegionResolution.actualSubjectCode = subjectCode at construction (RegionResolution::make()).
* They are equal at the resolver stage; substitution is a RouteSupplierLeadJob concern only.
*
* X3 region_source values:
* 'dadata' FakeDaData stub with qc=0
* 'rossvyaz' FakeDaData stubThrows + seeded phone_ranges row matching the phone
* 'tag' DaData disabled OR qc=2/7 + valid tag string (FINDING: qc=2 with valid tag 'tag')
* 'unknown' DaData disabled/fails + no phone_range + empty/null tag
*
* X3 uses direct LeadRegionResolver::resolve() calls (not full routing) to
* produce multiple resolution log rows cheaply via separate SupplierLead rows.
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ────────────────────────────
/** RussianRegions::CODE_TO_NAME[29] = 'Красноярский край' — real lead region */
const X1_LEAD_REGION = 29;
/** RussianRegions::CODE_TO_NAME[37] = 'Белгородская область' — client's subscribed region */
const X1_CLIENT_REGION = 37;
/** RussianRegions::CODE_TO_NAME[82] = 'Москва' */
const X1_MOSCOW = 82;
/** Domains for X1 and X3 tests (B1 site signal, unique per scenario to avoid snapshot collisions) */
const X1_DOMAIN = 'scenario-x1-substitution.ru';
const X3_DOMAIN = 'scenario-x3-source-breakdown.ru';
/** Deterministic seed for LeadRouter */
const X1_SEED = 42;
// ── SHARED beforeEach ──────────────────────────────────────────────────────────
beforeEach(function (): void {
// Pricing tiers required by LedgerService::chargeForDelivery.
$this->seed(PricingTierSeeder::class);
// Global RLS bypass for seeding (tenant context = 0).
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// DaData config defaults — individual tests override as needed.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
// Deterministic LeadRouter — seeded Mt19937. With 1 candidate, weightedPick
// always returns it (pool ≤ cap=3 so no lottery needed), but seeded for stability.
app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(X1_SEED))));
});
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X1 — step-3 substitution: subject_code, city, journal actual/substituted
// ══════════════════════════════════════════════════════════════════════════════
/**
* X1: One client subscribed to R_client (Белгородская обл., code 37).
* No all-RF client. Lead resolved to R_lead (Красноярский край, code 29) via DaData qc=0.
*
* Cascade:
* Phase 1 exact: 29 NOT in client's regions={37} empty.
* Phase 2 all-RF: no '{}' client empty.
* Phase 3 fallback: client eligible (any region) routing_step=3.
*
* Expected (prod spec §3.10 + §7 п.30/41):
* deals.subject_code = R_client = 37 (substituted to client's region)
* deals.city = name(R_lead) = 'Красноярский край' (REAL lead region)
* deals.region_substituted = true
* lead_region_resolution_log.actual_subject_code = R_lead = 29
* lead_region_resolution_log.substituted_subject_code = R_client = 37
* lead_region_resolution_log.subject_code_resolved = R_lead = 29
* lead_region_resolution_log.routing_step = 3
*/
it('X1: step-3 fallback substitutes subject_code to client region, preserves real region in city + journal', function (): void {
// ── ARRANGE ───────────────────────────────────────────────────────────────
// One supplier (B1 site).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X1_DOMAIN,
]);
// One client: subscribed to R_client=37 (Белгородская обл.) ONLY.
// No all-RF client → phases 1+2 both empty → phase 3 fires.
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => X1_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [X1_CLIENT_REGION], // {37}
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: X1_DOMAIN,
dailyLimit: 10,
regions: '{' . X1_CLIENT_REGION . '}', // '{37}'
);
// Lead resolves to R_lead=29 (Красноярский край) via DaData qc=0.
// DaData region string must EXACTLY match RussianRegions::CODE_TO_NAME[29]
// for DaDataRegionMap::toSubjectCode() to return code 29.
$leadPhone = '79292900001';
$leadRegionName = RussianRegions::CODE_TO_NAME[X1_LEAD_REGION]; // 'Красноярский край'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData->stub($leadPhone, qc: 0, region: $leadRegionName, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ───────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$lead = $injector->site(
domain: X1_DOMAIN,
phone: $leadPhone,
tag: null,
platform: 'B1',
vid: 9_120_001_001,
);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Retrieve the deal for our tenant.
$deal = DB::connection('pgsql_supplier')
->table('deals')
->where('tenant_id', $tenant->id)
->first();
$logRow = DB::connection('pgsql_supplier')
->table('lead_region_resolution_log')
->where('supplier_lead_id', $lead->id)
->first();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL . '=== X1 SUBSTITUTION ===' . PHP_EOL);
fwrite(STDOUT, "Lead phone: {$leadPhone}" . PHP_EOL);
fwrite(STDOUT, "R_lead (real resolved): " . X1_LEAD_REGION . " ({$leadRegionName})" . PHP_EOL);
fwrite(STDOUT, "R_client (client region): " . X1_CLIENT_REGION . " (" . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . ")" . PHP_EOL);
fwrite(STDOUT, "deal found: " . ($deal !== null ? 'YES' : 'NO') . PHP_EOL);
if ($deal !== null) {
fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}" . PHP_EOL);
fwrite(STDOUT, "deals.city: {$deal->city}" . PHP_EOL);
fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}" . PHP_EOL);
}
if ($logRow !== null) {
fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}" . PHP_EOL);
fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}" . PHP_EOL);
fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.region_source: {$logRow->region_source}" . PHP_EOL);
} else {
fwrite(STDOUT, "log row: NOT FOUND" . PHP_EOL);
}
fwrite(STDOUT, '=== END X1 ===' . PHP_EOL . PHP_EOL);
// A deal must have been created.
expect($deal)->not->toBeNull(
'FINDING: No deal was created for the tenant. ' .
'The phase-3 fallback (any region) may not be reaching this client, ' .
'or the snapshot is missing.'
);
if ($deal !== null) {
// deals.subject_code must be R_client (substituted to client's region on step 3).
expect((int) $deal->subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: deals.subject_code should be ' . X1_CLIENT_REGION . ' (R_client, Белгородская обл.) ' .
'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. ' .
'Got: ' . $deal->subject_code . '. ' .
'If got R_lead=' . X1_LEAD_REGION . ': substitution is not firing (routingStep capture or pickSubstituteRegion failed). ' .
'If got null: snapshot.regions may not be picked up correctly.'
);
// deals.city must be the name of R_lead (real resolved region), NOT R_client.
// Code §3.10 comment: «Город» = человекочитаемое имя НАСТОЯЩЕГО региона лида.
expect($deal->city)->toBe($leadRegionName,
'FINDING: deals.city should be "' . $leadRegionName . '" (name of R_lead=' . X1_LEAD_REGION . ', real lead region). ' .
'Got: "' . $deal->city . '". ' .
'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. ' .
'If city = "' . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . '": ' .
'prod code erroneously uses $dealSubjectCode for city.'
);
// deals.region_substituted must be true.
// PostgreSQL boolean comes back as string '1'/'0'/'t'/'f' or bool depending on driver.
$regionSubstituted = filter_var($deal->region_substituted, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($regionSubstituted === null) {
// Raw value from DB — accept truthy string/int representations.
$regionSubstituted = in_array($deal->region_substituted, [true, 1, '1', 't', 'true'], true);
}
expect($regionSubstituted)->toBeTrue(
'FINDING: deals.region_substituted should be TRUE on routing_step=3. ' .
'Got raw value: "' . $deal->region_substituted . '". ' .
'Check RouteSupplierLeadJob line: \'region_substituted\' => $routingStep === 3.'
);
}
// lead_region_resolution_log must have a row.
expect($logRow)->not->toBeNull(
'FINDING: lead_region_resolution_log has no row for this lead. ' .
'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). ' .
'Check for partition missing (received_at date not matching any partition).'
);
if ($logRow !== null) {
// routing_step = 3.
expect((int) $logRow->routing_step)->toBe(3,
'FINDING: log.routing_step should be 3 (phase-3 fallback). ' .
'Got: ' . $logRow->routing_step . '. ' .
'If 1: exact match fired (snapshot.regions may be wrong). ' .
'If 2: all-RF match fired (client has regions=\'{}\' or snapshot is wrong).'
);
// subject_code_resolved = R_lead (the actual resolved code, not substituted).
expect((int) $logRow->subject_code_resolved)->toBe(X1_LEAD_REGION,
'FINDING: log.subject_code_resolved should be ' . X1_LEAD_REGION . ' (R_lead, real resolved). ' .
'Got: ' . $logRow->subject_code_resolved . '.'
);
// actual_subject_code = R_lead.
// RegionResolution.actualSubjectCode = subjectCode at construction time (real resolved).
expect((int) $logRow->actual_subject_code)->toBe(X1_LEAD_REGION,
'FINDING: log.actual_subject_code should be ' . X1_LEAD_REGION . ' (R_lead, real lead region). ' .
'Got: ' . $logRow->actual_subject_code . '. ' .
'actualSubjectCode is set equal to subjectCode in RegionResolution::make().'
);
// substituted_subject_code = R_client (the first code from snapshot.regions).
// pickSubstituteRegion('{37}') → 37 = X1_CLIENT_REGION.
expect($logRow->substituted_subject_code)->not->toBeNull(
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client) on step 3. ' .
'Got null. ' .
'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. ' .
'Check that $first->snapshot_regions attribute is present (set by LeadRouter SQL SELECT).'
);
if ($logRow->substituted_subject_code !== null) {
expect((int) $logRow->substituted_subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client=Белгородская обл.). ' .
'Got: ' . $logRow->substituted_subject_code . '. ' .
'pickSubstituteRegion() parses PG INT[]-literal \'{37}\' → [37] → first=37.'
);
}
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X3 — region_source breakdown (dadata / rossvyaz / tag / unknown)
// ══════════════════════════════════════════════════════════════════════════════
/**
* X3: Inject 4 leads with different region_source values, aggregate
* lead_region_resolution_log.region_source counts, assert they match.
*
* To avoid full routing overhead and snapshot complexity, X3 uses
* LeadRegionResolver::resolve() directly on SupplierLead rows,
* then reads region_source from the updated supplier_leads columns.
* The resolver writes region_source to supplier_leads.region_source
* (RouteSupplierLeadJob lines 159-164); the log is written by
* logRegionResolution() after routing. For X3 we inject via full
* LeadInjector (which fires RouteSupplierLeadJob) so logRegionResolution()
* also runs; however, without a client snapshot the routing loop produces
* no deals (no selected projects logRegionResolution called with empty $selected).
*
* FINDING note on log.routing_step when $selected is empty:
* logRegionResolution() line 561: $first = $selected->first() null.
* So $routingStep = null, $substituted = null.
* This is correct/expected for X3's source-breakdown scenario.
*
* Source classification (from LeadRegionResolver code):
* 'dadata' DaData enabled, qc=0 (good quality, map returns a code).
* 'rossvyaz' DaData disabled OR throws/qc=1, phone_ranges row seeded for phone.
* 'tag' DaData disabled/fails/qc=2, valid tag string (maps to a region code).
* 'unknown' DaData disabled/fails, no phone_range match, empty/null tag.
*
* We inject 1 lead per source type (4 total), then read supplier_leads.region_source.
* Counts: dadata=1, rossvyaz=1, tag=1, unknown=1.
*
* X3 uses a dedicated supplier + NO snapshot selected=empty no deals created.
* This avoids the routing infrastructure (no client setup, no snapshot needed).
* The region resolver still runs and writes region_source to supplier_leads.
*/
it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_source counts in supplier_leads', function (): void {
// ── ARRANGE ───────────────────────────────────────────────────────────────
// Supplier for X3 — NO project linked, NO snapshot → routing produces 0 deals.
// This means RouteSupplierLeadJob still runs LeadRegionResolver, updates
// supplier_leads.region_source, then calls logRegionResolution (with empty selected).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X3_DOMAIN,
]);
// ── Lead 1: source = 'dadata' ──────────────────────────────────────────────
// DaData returns qc=0 + valid region name → DaDataRegionMap maps to a code.
$phone1 = '79310000001';
$region1Name = RussianRegions::CODE_TO_NAME[X1_MOSCOW]; // 'Москва'
$fake1 = new FakeDaDataPhoneClient();
$fake1->stub($phone1, qc: 0, region: $region1Name, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake1);
config(['services.dadata.enabled' => true]);
$injector = new LeadInjector();
$lead1 = $injector->site(domain: X3_DOMAIN, phone: $phone1, tag: null, platform: 'B1', vid: 9_130_003_001);
// ── Lead 2: source = 'rossvyaz' ────────────────────────────────────────────
// DaData throws → falls through to Россвязь lookup. Seed a phone_ranges row
// covering phone2's DEF+subscriber range, mapping to subject_code=X1_MOSCOW.
// Phone format: 7{defCode}{subscriber} = 7{931}{0000002} → DEF=931, sub=0000002.
$phone2 = '79310000002';
// DEF=931, subscriber=0000002. seed range from=0 to=9999999 covering it.
$importId2 = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://x3-rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', 'x3-rossvyaz-931'),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => 931,
'from_num' => 0,
'to_num' => 9999999,
'operator' => 'test-op-x3',
'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW],
'region_normalized' => null,
'subject_code' => X1_MOSCOW,
'imported_at' => now(),
'import_id' => $importId2,
]);
$fake2 = new FakeDaDataPhoneClient();
$fake2->stubThrows($phone2); // DaData throws → cascade falls to Россвязь
app()->instance(DaDataPhoneClient::class, $fake2);
config(['services.dadata.enabled' => true]);
$lead2 = $injector->site(domain: X3_DOMAIN, phone: $phone2, tag: null, platform: 'B1', vid: 9_130_003_002);
// ── Lead 3: source = 'tag' ─────────────────────────────────────────────────
// DaData disabled → no HTTP call. No phone_range seeded for this phone.
// Tag = region name that RegionTagResolver recognises as a valid region code.
// RegionTagResolver maps tag text to a subject code. Use 'Москва' (maps to 82).
// FINDING note: qc=2 path calls tagFallback() which only returns 'tag' if tagCode != null.
// With DaData disabled, resolver falls directly to tag/rossvyaz cascade.
$phone3 = '79310000003';
config(['services.dadata.enabled' => false]);
// No DaData stub needed — disabled path skips the HTTP call entirely.
$lead3 = $injector->site(domain: X3_DOMAIN, phone: $phone3, tag: 'Москва', platform: 'B1', vid: 9_130_003_003);
// ── Lead 4: source = 'unknown' ─────────────────────────────────────────────
// DaData disabled. No phone_ranges row for this DEF. Empty tag.
// → no resolution possible → source='unknown'.
$phone4 = '79880000004'; // DEF=988, NOT seeded in phone_ranges
config(['services.dadata.enabled' => false]);
$lead4 = $injector->site(domain: X3_DOMAIN, phone: $phone4, tag: null, platform: 'B1', vid: 9_130_003_004);
// ── READ region_source from supplier_leads ────────────────────────────────
// RouteSupplierLeadJob updates supplier_leads.region_source after resolver runs.
$leadIds = [$lead1->id, $lead2->id, $lead3->id, $lead4->id];
$rows = DB::table('supplier_leads')
->whereIn('id', $leadIds)
->get(['id', 'region_source', 'resolved_subject_code', 'phone'])
->keyBy('id');
$source1 = $rows[$lead1->id]->region_source ?? 'MISSING';
$source2 = $rows[$lead2->id]->region_source ?? 'MISSING';
$source3 = $rows[$lead3->id]->region_source ?? 'MISSING';
$source4 = $rows[$lead4->id]->region_source ?? 'MISSING';
fwrite(STDOUT, PHP_EOL . '=== X3 SOURCE BREAKDOWN ===' . PHP_EOL);
fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: " . ($rows[$lead1->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: " . ($rows[$lead2->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: " . ($rows[$lead3->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: " . ($rows[$lead4->id]->resolved_subject_code ?? 'null') . PHP_EOL);
// Aggregate counts from supplier_leads.
$actualSources = array_map(fn ($id) => $rows[$id]->region_source ?? 'MISSING', $leadIds);
$counts = array_count_values($actualSources);
fwrite(STDOUT, 'Source counts: ' . json_encode($counts, JSON_UNESCAPED_UNICODE) . PHP_EOL);
fwrite(STDOUT, '=== END X3 ===' . PHP_EOL . PHP_EOL);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Lead 1: expect 'dadata'.
expect($source1)->toBe('dadata',
"FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. " .
"Got: '{$source1}'. " .
"If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). " .
"If 'unknown': DaData is disabled or threw despite stub."
);
// Lead 2: expect 'rossvyaz'.
expect($source2)->toBe('rossvyaz',
"FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. " .
"Got: '{$source2}'. " .
"If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). " .
"If 'dadata': FakeDaDataPhoneClient stubThrows didn't fire (stub registration issue)."
);
// Lead 3: expect 'tag'.
expect($source3)->toBe('tag',
"FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. " .
"Got: '{$source3}'. " .
"KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through " .
"Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() " .
"is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). " .
"'Москва' should map to code 82 via RegionTagResolver. " .
"If 'unknown': tag 'Москва' did not map to a region code in RegionTagResolver."
);
// Lead 4: expect 'unknown'.
expect($source4)->toBe('unknown',
"FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. " .
"Got: '{$source4}'. " .
"If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. " .
"If 'tag': empty/null tag somehow resolved to a code (check RegionTagResolver null-tag handling)."
);
// Aggregate assertion: 4 leads → exactly these 4 sources.
$expectedCounts = ['dadata' => 1, 'rossvyaz' => 1, 'tag' => 1, 'unknown' => 1];
expect($counts)->toEqual($expectedCounts,
'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. ' .
'Got: ' . json_encode($counts) . '. See individual source assertions above for details.'
);
})->group('imitation');
+222
View File
@@ -0,0 +1,222 @@
<?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');
@@ -0,0 +1,61 @@
<?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');
@@ -0,0 +1,862 @@
<?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,13 +79,6 @@ 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'));
@@ -145,3 +138,25 @@ 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();
});
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
});
function runRegionJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
/**
* Создаёт маршрутизируемый лид: supplier B1 site + tenant с балансом + project + snapshot.
*
* @return array{0: SupplierLead, 1: Project, 2: Tenant, 3: SupplierProject}
*/
function seedRoutableLead(string $regions, string $tag, string $phone, string $key = 'vashinvestor.ru'): array
{
$supplier = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site', 'signal_identifier' => $key,
'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0,
'daily_limit_target' => 100,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, dailyLimit: 100, regions: $regions);
$vid = 432176649;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => $phone,
'received_at' => now(),
'raw_payload' => [
'vid' => $vid, 'project' => "B1_{$key}", 'tag' => $tag,
'phone' => $phone, 'phones' => [$phone], 'time' => now()->getTimestamp(),
],
]);
return [$lead, $project, $tenant, $supplier];
}
function dealFor(int $tenantId, int $projectId): ?Deal
{
DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'");
$deal = Deal::query()->where('project_id', $projectId)->first();
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
return $deal;
}
it('lead with phone uses dadata region, not the tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', 'phone' => '+7 916 123-45-67',
]], 200)]);
// tag='Санкт-Петербург' (дал бы 83), но телефон резолвится в Москву (82).
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Санкт-Петербург', phone: '79161234567');
runRegionJob($lead->id);
$lead->refresh();
expect($lead->resolved_subject_code)->toBe(82)
->and($lead->region_source)->toBe('dadata')
->and($lead->phone_operator)->toBe('МТС');
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and((int) $deal->subject_code)->toBe(82) // регион из DaData, не из тега (83)
->and((bool) $deal->region_substituted)->toBeFalse()
->and($deal->phone_operator)->toBe('МТС');
});
it('logs exactly one region resolution row per lead', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
[$lead] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
runRegionJob($lead->id);
$rows = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->get();
expect($rows)->toHaveCount(1);
expect($rows->first()->region_source)->toBe('dadata');
// Телефон в логе маскирован (не сырой номер) — §7.1.
expect($rows->first()->phone_masked)->not->toBe('79161234567');
});
it('lead with invalid phone falls back to tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
// Невалидный телефон → DaData не дёргается → tag (Москва=82).
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '123');
runRegionJob($lead->id);
$lead->refresh();
expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
Http::assertNothingSent();
});
it('lead with resolver disabled via flag uses tag', function (): void {
config(['services.dadata.enabled' => false]);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
runRegionJob($lead->id);
$lead->refresh();
expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
Http::assertNothingSent();
});
it('persistent idempotency: pre-resolved lead does not re-call dadata', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
// Эмулируем предыдущий try: резолв уже персистнут.
$lead->update(['resolved_subject_code' => 83, 'region_source' => 'rossvyaz', 'phone_operator' => 'МегаФон']);
runRegionJob($lead->id);
Http::assertNothingSent(); // §3.11 — нет двойной оплаты DaData
$lead->refresh();
expect($lead->resolved_subject_code)->toBe(83)->and($lead->region_source)->toBe('rossvyaz');
});
it('step-3 fallback substitutes subject_code to client region and flags region_substituted', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
// Лид по Москве (82), но клиент подписан только на Питер (83): точных нет, «вся РФ» нет → шаг 3.
[$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
runRegionJob($lead->id);
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and((int) $deal->subject_code)->toBe(83) // подменён на регион клиента (Питер)
->and((bool) $deal->region_substituted)->toBeTrue();
// Настоящий регион (Москва=82) сохранён в журнале как actual_subject_code.
$log = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->first();
expect((int) $log->actual_subject_code)->toBe(82)
->and((int) $log->substituted_subject_code)->toBe(83);
});
it('csv-merge updates subject_code and operator when webhook resolution outranks tag (dadata)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
// CSV-recovered сделка: source_crm_id=null, регион из тега «неправильный» (53 = ЛО).
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$csvDeal = Deal::create([
'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
'received_at' => now(), 'subject_code' => 53,
]);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
runRegionJob($lead->id);
$merged = dealFor($tenant->id, $project->id);
expect((int) $merged->id)->toBe($csvDeal->id) // merge в существующую, не новая
->and((int) $merged->subject_code)->toBe(82) // обновлено DaData (82) поверх tag (53)
->and($merged->phone_operator)->toBe('МТС')
->and((int) $merged->source_crm_id)->toBe($lead->vid);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('project_id', $project->id)->count())->toBe(1); // второй сделки нет
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
it('csv-merge does not overwrite subject_code when webhook resolution is tag-level', function (): void {
config(['services.dadata.enabled' => false]); // резолвер выключен → source='tag' (rank не выше CSV-tag)
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
Deal::create([
'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
'received_at' => now(), 'subject_code' => 53,
]);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
runRegionJob($lead->id);
$merged = dealFor($tenant->id, $project->id);
expect((int) $merged->subject_code)->toBe(53); // tag не выше tag → регион не тронут
});
@@ -631,3 +631,35 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
// Никаких дублей deals — только один с этим vid.
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
});
it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
\Illuminate\Support\Facades\Http::fake(['cleaner.dadata.ru/*' => \Illuminate\Support\Facades\Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
runRouteJob($lead->id);
// deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and($deal->city)->toBe('Москва');
});
it('leaves deal city null when region is unknown', function (): void {
config(['services.dadata.enabled' => false]);
// Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');
runRouteJob($lead->id);
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and($deal->city)->toBeNull();
});
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(SharesSupplierPdo::class);
it('creates phone_ranges with lookup columns', function (): void {
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->t)->not->toBeNull();
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges'"))
->pluck('column_name')->all();
expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
});
it('creates phone_ranges_imports journal table', function (): void {
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges_imports') AS t")->t)->not->toBeNull();
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges_imports'"))
->pluck('column_name')->all();
expect($cols)->toContain('source_url', 'checksum_sha256', 'status', 'rows_inserted', 'rows_updated');
});
it('creates lead_region_resolution_log as a partitioned table', function (): void {
$partitioned = DB::selectOne(
"SELECT 1 AS ok
FROM pg_partitioned_table pt
JOIN pg_class c ON c.oid = pt.partrelid
WHERE c.relname = 'lead_region_resolution_log'"
);
expect($partitioned)->not->toBeNull();
});
it('adds resolution columns to supplier_leads', function (): void {
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_leads'"))
->pluck('column_name')->all();
expect($cols)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
});
it('adds resolution columns to deals', function (): void {
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'deals'"))
->pluck('column_name')->all();
expect($cols)->toContain('phone_operator', 'region_substituted');
});
@@ -76,10 +76,11 @@ test('идемпотентность: повторный запуск не па
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
// (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
$expectedSkipped = count(\App\Services\MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
$output = Artisan::output();
expect($output)->toContain('0 created, 48 skipped');
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
});
test('--ahead=0 создаёт только текущий месяц', function () {
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataBudgetGuard;
it('allows spend while under the daily cap', function (): void {
config(['services.dadata.daily_cap_rub' => 10]); // 1000 копеек
$guard = app(DaDataBudgetGuard::class);
expect($guard->canSpend())->toBeTrue();
$guard->recordSpend(500);
expect($guard->canSpend())->toBeTrue()
->and($guard->spentTodayKopecks())->toBe(500);
});
it('blocks spend once the daily cap is reached', function (): void {
config(['services.dadata.daily_cap_rub' => 1]); // 100 копеек
$guard = app(DaDataBudgetGuard::class);
$guard->recordSpend(100);
expect($guard->canSpend())->toBeFalse();
});
it('accumulates spend across multiple calls', function (): void {
config(['services.dadata.daily_cap_rub' => 100]);
$guard = app(DaDataBudgetGuard::class);
$guard->recordSpend(30);
$guard->recordSpend(70);
expect($guard->spentTodayKopecks())->toBe(100);
});
it('starts at zero spend for a fresh day', function (): void {
$guard = app(DaDataBudgetGuard::class);
expect($guard->spentTodayKopecks())->toBe(0);
});
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataTimeoutException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
it('parses qc=0 mobile response into DTO', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'qc_conflict' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34',
'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', 'city' => null, 'timezone' => 'UTC+3',
]], 200)]);
$resp = app(DaDataPhoneClient::class)->cleanPhone('79215551234');
expect($resp->qc)->toBe(0)
->and($resp->provider)->toBe('МегаФон')
->and($resp->region)->toBe('Санкт-Петербург и область')
->and($resp->type)->toBe('Мобильный')
->and($resp->raw)->toBeArray();
});
it('parses qc=3 multiple response', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 3, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный',
]], 200)]);
expect(app(DaDataPhoneClient::class)->cleanPhone('79991234567')->qc)->toBe(3);
});
it('sends Token auth, X-Secret header and json-array body', function (): void {
config(['services.dadata.api_key' => 'KEY', 'services.dadata.secret' => 'SEC']);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
app(DaDataPhoneClient::class)->cleanPhone('79161234567');
Http::assertSent(function ($request): bool {
return $request->url() === 'https://cleaner.dadata.ru/api/v1/clean/phone'
&& $request->hasHeader('Authorization', 'Token KEY')
&& $request->hasHeader('X-Secret', 'SEC')
&& $request->body() === '["79161234567"]';
});
});
it('throws DaDataTimeoutException on connection error', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
->toThrow(DaDataTimeoutException::class);
});
it('throws DaDataException on persistent 5xx', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response('upstream error', 500)]);
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
->toThrow(DaDataException::class);
});
it('retries once on 5xx then succeeds', function (): void {
Http::fakeSequence('cleaner.dadata.ru/*')
->push('upstream error', 500)
->push([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200);
$resp = app(DaDataPhoneClient::class)->cleanPhone('79161234567');
expect($resp->qc)->toBe(0);
Http::assertSentCount(2);
});
it('does not retry on 4xx client error', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response('bad request', 400)]);
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79161234567'))
->toThrow(DaDataException::class);
Http::assertSentCount(1);
});
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 10000,
]);
});
function resolverSeedImport(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'test', 'checksum_sha256' => str_repeat('b', 64),
'status' => 'completed', 'imported_at' => now(),
]);
}
function resolverSeedRange(int $subject, string $region = 'Москва', int $def = 916, string $operator = 'Ростелеком'): void
{
DB::table('phone_ranges')->insert([
'def_code' => $def, 'from_num' => 0, 'to_num' => 9999999,
'operator' => $operator, 'region' => $region, 'subject_code' => $subject,
'imported_at' => now(), 'import_id' => resolverSeedImport(),
]);
}
function resolverLead(string $phone = '79161234567', string $tag = ''): SupplierLead
{
return new SupplierLead([
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
'received_at' => now(),
]);
}
function fakeDadata(array $row): void
{
Http::fake(['cleaner.dadata.ru/*' => Http::response([$row], 200)]);
}
it('dadata qc 0 returns dadata source', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный']);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('dadata')
->and($r->subjectCode)->toBe(82)
->and($r->phoneOperator)->toBe('МТС')
->and($r->qc)->toBe(0)
->and($r->cacheHit)->toBeFalse();
});
it('dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Санкт-Петербург и область', 'provider' => 'МегаФон']);
resolverSeedRange(subject: 83, region: 'Санкт-Петербург');
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')
->and($r->subjectCode)->toBe(83)
->and($r->phoneOperator)->toBe('МегаФон') // оператор от DaData (MNP), §3.4.1
->and($r->rossvyazMatched)->toBeTrue();
});
it('dadata qc 3 returns dadata with multiple flag', function (): void {
fakeDadata(['qc' => 3, 'region' => 'Москва', 'provider' => 'МТС']);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('dadata')->and($r->subjectCode)->toBe(82)->and($r->qc)->toBe(3);
});
it('dadata qc 1 falls back to rossvyaz', function (): void {
fakeDadata(['qc' => 1, 'region' => 'Москва', 'provider' => 'Билайн']);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('dadata qc 2 falls back to tag skipping rossvyaz', function (): void {
fakeDadata(['qc' => 2]);
resolverSeedRange(subject: 83); // если бы Россвязь дёрнули — был бы 83
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82)->and($r->rossvyazMatched)->toBeFalse();
});
it('dadata qc 7 falls back to tag skipping rossvyaz', function (): void {
fakeDadata(['qc' => 7]);
resolverSeedRange(subject: 83);
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
});
it('dadata timeout falls back to rossvyaz', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('dadata network error 5xx falls back to rossvyaz', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response('err', 500)]);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('budget cap exceeded skips dadata directly to rossvyaz', function (): void {
config(['services.dadata.daily_cap_rub' => 0]); // canSpend() → false
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
Http::assertNothingSent();
});
it('cache hit skips dadata and rossvyaz on the second call', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']);
$resolver = app(LeadRegionResolver::class);
$first = $resolver->resolve(resolverLead());
$second = $resolver->resolve(resolverLead());
expect($first->cacheHit)->toBeFalse()
->and($second->cacheHit)->toBeTrue()
->and($second->subjectCode)->toBe(82);
Http::assertSentCount(1);
});
it('invalid phone skips dadata returns tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
$r = app(LeadRegionResolver::class)->resolve(resolverLead(phone: '123', tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
Http::assertNothingSent();
});
it('qc 0 region null falls through to rossvyaz', function (): void {
fakeDadata(['qc' => 0, 'region' => null, 'provider' => 'Tele2']);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82)->and($r->phoneOperator)->toBe('Tele2');
});
it('unmappable dadata region falls through to rossvyaz', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Несуществующий край', 'provider' => 'МТС']);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('all three layers fail returns unknown with null subject_code', function (): void {
fakeDadata(['qc' => 1]); // → rossvyaz
// no phone_ranges seeded → rossvyaz miss; tag empty → null
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: ''));
expect($r->source)->toBe('unknown')->and($r->subjectCode)->toBeNull();
});
it('disabled feature flag returns tag without any dadata call', function (): void {
config(['services.dadata.enabled' => false]);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
Http::assertNothingSent();
});
it('persistent idempotency: already-resolved lead skips dadata', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
$lead = resolverLead();
$lead->resolved_subject_code = 83;
$lead->region_source = 'dadata';
$lead->dadata_qc = 0;
$lead->phone_operator = 'МегаФон';
$r = app(LeadRegionResolver::class)->resolve($lead);
expect($r->subjectCode)->toBe(83)->and($r->source)->toBe('dadata');
Http::assertNothingSent();
});
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/** Детерминированный роутер с засеянным жребием (вариант В). */
function seededRouter(int $seed = 42): LeadRouter
{
return new LeadRouter(new Randomizer(new Mt19937($seed)));
}
/**
* Создаёт tenant + project + pivot/snapshot для каскад-тестов.
* regions PG-массив-литерал ('{82}' / '{}'); remaining лимита = dailyLimit - deliveredToday.
*/
function makeCascadeProject(
SupplierProject $sp,
string $regions,
int $dailyLimit = 100,
int $deliveredToday = 0,
): Project {
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'daily_limit_target' => $dailyLimit,
'delivered_today' => $deliveredToday,
'delivery_days_mask' => 127,
'signal_type' => $sp->signal_type,
'signal_identifier' => $sp->unique_key,
]);
linkProjectToSupplier($project, $sp);
createRoutingSnapshotFromProject(
$project,
signalType: $sp->signal_type,
signalIdentifier: $sp->unique_key,
dailyLimit: $dailyLimit,
regions: $regions,
);
return $project;
}
function b1Supplier(string $key = 'ex.ru'): SupplierProject
{
return SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
}
it('step 1: exact region match wins, others excluded', function (): void {
$sp = b1Supplier();
$spb = makeCascadeProject($sp, regions: '{83}'); // Питер
$msk = makeCascadeProject($sp, regions: '{82}'); // Москва
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$msk->id])
->and($matched->first()->routing_step)->toBe(1);
});
it('step 2: falls to all-RF when no exact match', function (): void {
$sp = b1Supplier('s2.ru');
$allRu = makeCascadeProject($sp, regions: '{}'); // вся РФ
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$allRu->id])
->and($matched->first()->routing_step)->toBe(2);
});
it('step 3: fallback channel when nobody subscribed to region and no all-RF', function (): void {
$sp = b1Supplier('s3.ru');
$spb = makeCascadeProject($sp, regions: '{83}'); // только Питер подписан
// resolvedSubjectCode=82 (Москва): точных нет, «вся РФ» нет → запасной канал.
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$spb->id])
->and($matched->first()->routing_step)->toBe(3);
});
it('exact + all-RF combine up to cap=3, exact taking priority', function (): void {
$sp = b1Supplier('s4.ru');
$e1 = makeCascadeProject($sp, regions: '{82}');
$e2 = makeCascadeProject($sp, regions: '{82}');
$r1 = makeCascadeProject($sp, regions: '{}');
$r2 = makeCascadeProject($sp, regions: '{}');
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
// Всего 3 (cap). Оба точных (step 1) обязаны быть; добор — ровно 1 «вся РФ» (step 2).
expect($matched)->toHaveCount(3);
$byStep = $matched->groupBy(fn ($p) => $p->routing_step);
expect($byStep->get(1)->pluck('id')->sort()->values()->all())->toBe(collect([$e1->id, $e2->id])->sort()->values()->all())
->and($byStep->get(2))->toHaveCount(1);
expect(in_array($byStep->get(2)->first()->id, [$r1->id, $r2->id], true))->toBeTrue();
});
it('null resolvedSubjectCode skips exact, uses all-RF', function (): void {
$sp = b1Supplier('s5.ru');
$allRu = makeCascadeProject($sp, regions: '{}');
$exact = makeCascadeProject($sp, regions: '{82}');
// Резолвер не сработал → шаг 1 пропускается; матчит только «вся РФ».
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: null);
expect($matched->pluck('id')->all())->toBe([$allRu->id])
->and($matched->first()->routing_step)->toBe(2);
});
it('cascade works for DIRECT supplier_project path too', function (): void {
$sp = SupplierProject::query()->create([
'platform' => 'DIRECT', 'signal_type' => 'site', 'unique_key' => 'cashmotor.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$msk = makeCascadeProject($sp, regions: '{82}');
$spb = makeCascadeProject($sp, regions: '{83}');
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$msk->id])
->and($matched->first()->routing_step)->toBe(1);
});
it('backward compat: no second arg behaves as all-RF/any (existing call shape)', function (): void {
$sp = b1Supplier('s7.ru');
$allRu = makeCascadeProject($sp, regions: '{}');
// Старая сигнатура (без 2-го аргумента) — дефолт null → шаг 2 all-RF матчит '{}'.
$matched = seededRouter()->matchEligibleProjects($sp);
expect($matched->pluck('id')->all())->toBe([$allRu->id]);
});
it('variant В: weighted pick — small client never starved, big client wins more often', function (): void {
$sp = b1Supplier('fair.ru');
// 5 клиентов на Москву, разный остаток лимита.
$a = makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); // остаток 100
$b = makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
$c = makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
$d = makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
$e = makeCascadeProject($sp, regions: '{82}', dailyLimit: 10); // остаток 10 — самый маленький
$wins = [];
$seedCount = 120;
for ($seed = 0; $seed < $seedCount; $seed++) {
$matched = seededRouter($seed)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched)->toHaveCount(3); // лид всегда раздаётся ровно троим
foreach ($matched as $p) {
$wins[$p->id] = ($wins[$p->id] ?? 0) + 1;
}
}
// (1) Мелкого не отрезаем: за 120 розыгрышей хотя бы раз получил лид.
expect($wins[$e->id] ?? 0)->toBeGreaterThan(0);
// (2) Вес уважается: крупный клиент выигрывает строго чаще мелкого.
expect($wins[$a->id] ?? 0)->toBeGreaterThan($wins[$e->id] ?? 0);
});
it('variant В: deterministic — same seed yields same recipients', function (): void {
$sp = b1Supplier('det.ru');
makeCascadeProject($sp, regions: '{82}', dailyLimit: 100);
makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
$first = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
$second = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
expect($first)->toBe($second)->and($first)->toHaveCount(3);
});
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Services\Dto\RegionResolution;
it('exposes the source rank ordering dadata>rossvyaz>tag>unknown', function (): void {
expect(RegionResolution::SOURCE_RANK)->toBe([
'dadata' => 4, 'rossvyaz' => 3, 'tag' => 2, 'unknown' => 1,
]);
});
it('make sets actualSubjectCode equal to subjectCode', function (): void {
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0);
expect($r->subjectCode)->toBe(82)
->and($r->actualSubjectCode)->toBe(82)
->and($r->source)->toBe('dadata')
->and($r->phoneOperator)->toBe('МТС')
->and($r->qc)->toBe(0)
->and($r->cacheHit)->toBeFalse()
->and($r->rossvyazMatched)->toBeFalse();
});
it('fromTag builds a tag-sourced resolution', function (): void {
$r = RegionResolution::fromTag(82);
expect($r->subjectCode)->toBe(82)
->and($r->source)->toBe('tag')
->and($r->phoneOperator)->toBeNull();
});
it('fromSupplierLead reconstructs a persisted resolution (idempotency)', function (): void {
$lead = new SupplierLead([
'resolved_subject_code' => 83,
'region_source' => 'dadata',
'dadata_qc' => 0,
'phone_operator' => 'МегаФон',
]);
$r = RegionResolution::fromSupplierLead($lead);
expect($r->subjectCode)->toBe(83)
->and($r->source)->toBe('dadata')
->and($r->phoneOperator)->toBe('МегаФон')
->and($r->qc)->toBe(0);
});
it('withCacheHit flips the flag and clears the per-call masked response', function (): void {
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => '7916***4567']);
$hit = $r->withCacheHit(true);
expect($hit->cacheHit)->toBeTrue()
->and($hit->subjectCode)->toBe(82)
->and($hit->dadataResponseMasked)->toBeNull();
});
it('forCache strips per-call fields before storing', function (): void {
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => 'x'], durationMs: 120);
$c = $r->forCache();
expect($c->dadataResponseMasked)->toBeNull()
->and($c->durationMs)->toBeNull()
->and($c->cacheHit)->toBeFalse()
->and($c->subjectCode)->toBe(82)
->and($c->phoneOperator)->toBe('МТС');
});
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Services\Dto\RossvyazRecord;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Вставляет строку-журнал импорта и возвращает её id (import_id для phone_ranges).
*/
function seedRossvyazImport(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('a', 64),
'status' => 'completed',
'imported_at' => now(),
]);
}
/**
* @param array<string, mixed> $overrides
*/
function seedPhoneRange(array $overrides = []): void
{
DB::table('phone_ranges')->insert(array_merge([
'def_code' => 921,
'from_num' => 5550000,
'to_num' => 5559999,
'operator' => 'МегаФон',
'region' => 'Санкт-Петербург',
'subject_code' => 83,
'imported_at' => now(),
'import_id' => seedRossvyazImport(),
], $overrides));
}
it('mobile prefix returns correct region and operator', function (): void {
seedPhoneRange();
$rec = app(RossvyazPrefixLookup::class)->find('79215555123');
expect($rec)->toBeInstanceOf(RossvyazRecord::class)
->and($rec->subjectCode)->toBe(83)
->and($rec->region)->toBe('Санкт-Петербург')
->and($rec->operator)->toBe('МегаФон');
});
it('prefers narrower range when two ranges overlap', function (): void {
$importId = seedRossvyazImport();
// Широкий диапазон (вся 495-зона) — Московская область (56).
seedPhoneRange([
'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'Ростелеком', 'region' => 'Московская область',
'subject_code' => 56, 'import_id' => $importId,
]);
// Узкий диапазон внутри — Москва (82). Должен выиграть (ORDER BY width ASC).
seedPhoneRange([
'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999,
'operator' => 'МГТС', 'region' => 'Москва',
'subject_code' => 82, 'import_id' => $importId,
]);
$rec = app(RossvyazPrefixLookup::class)->find('74952005000');
expect($rec)->not->toBeNull()
->and($rec->subjectCode)->toBe(82)
->and($rec->region)->toBe('Москва');
});
it('returns null for unknown prefix', function (): void {
seedPhoneRange(); // только def_code=921
expect(app(RossvyazPrefixLookup::class)->find('79991234567'))->toBeNull();
});
it('returns null when subscriber number is outside any range', function (): void {
seedPhoneRange(['def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999]);
// def_code совпадает (921), но subscriber 4440000 вне [5550000, 5559999]
expect(app(RossvyazPrefixLookup::class)->find('79214440000'))->toBeNull();
});
it('returns null for malformed phone', function (): void {
seedPhoneRange();
expect(app(RossvyazPrefixLookup::class)->find('123'))->toBeNull();
});
-7
View File
@@ -38,11 +38,4 @@ describe('DealsFilters', () => {
});
expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
});
it('поле поиска имеет доступное имя (label) для скринридера', () => {
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
const label = w.find('[data-testid="filter-search-phone"] label');
expect(label.exists()).toBe(true);
expect(label.text()).toContain('Поиск по телефону');
});
});
-7
View File
@@ -47,11 +47,4 @@ describe('KanbanColumn.vue', () => {
expect(wrapper.emitted('openDeal')).toBeTruthy();
expect(wrapper.emitted('openDeal')?.[0]).toEqual([dealsForNew[0].id]);
});
// Контраст column-total на ивори чинится в scoped CSS (var(--accent) → нейтральный #4a463f),
// jsdom scoped-стили не вычисляет → числовую проверку контраста делает Pa11y. Здесь — структурный якорь.
it('column-total отрисован для пустой колонки', () => {
const wrapper = factory({ status, deals: [] });
expect(wrapper.find('.column-total').exists()).toBe(true);
});
});
-10
View File
@@ -49,14 +49,4 @@ describe('ProjectCard', () => {
});
expect(wrapper.text()).toContain('На паузе');
});
it('чип типа сигнала — flat-вариант с классом signal-chip (a11y контраст)', () => {
const wrapper = mount(ProjectCard, {
global: { plugins: [vuetify] },
props: { project: baseProject, selected: false },
});
const chip = wrapper.find('.signal-chip');
expect(chip.exists()).toBe(true);
expect(chip.classes()).toContain('v-chip--variant-flat');
});
});
+2 -1
View File
@@ -131,6 +131,7 @@ function createRoutingSnapshotFromProject(
string $signalType = 'call',
?string $signalIdentifier = null,
?int $dailyLimit = null,
string $regions = '{}',
): void {
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(),
@@ -138,7 +139,7 @@ function createRoutingSnapshotFromProject(
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127),
'regions' => '{}',
'regions' => $regions,
'signal_type' => $signalType,
'signal_identifier' => $signalIdentifier,
'sms_senders' => null,
@@ -0,0 +1,143 @@
<?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]);
}
}
@@ -0,0 +1,99 @@
<?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;
}
}
@@ -0,0 +1,281 @@
<?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,
];
}
}
@@ -0,0 +1,118 @@
<?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,
]);
}
}
@@ -0,0 +1,136 @@
<?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);
}
}
@@ -0,0 +1,67 @@
<?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,10 @@
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
it('knows lead_region_resolution_log partition key', function (): void {
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
});
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Support\DaDataRegionMap;
use App\Support\RussianRegions;
it('maps exact official names via RussianRegions', function (): void {
expect(DaDataRegionMap::toSubjectCode('Москва'))->toBe(82)
->and(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56)
->and(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83)
->and(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
});
it('trims surrounding whitespace before mapping', function (): void {
expect(DaDataRegionMap::toSubjectCode(' Москва '))->toBe(82);
});
it('flags ambiguous agglomeration strings', function (): void {
expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue()
->and(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue()
->and(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse()
->and(DaDataRegionMap::isAmbiguous('Санкт-Петербург'))->toBeFalse();
});
it('returns null for unmappable region', function (): void {
expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull()
->and(DaDataRegionMap::toSubjectCode(''))->toBeNull();
});
it('resolves all 89 RussianRegions names', function (): void {
foreach (RussianRegions::CODE_TO_NAME as $code => $name) {
expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
}
});
+4
View File
@@ -0,0 +1,4 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;Москва
921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
999;0000000;0009999;10000;Тест Оператор;Атлантида
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС Москва
3 921 5550000 5559999 10000 ПАО МегаФон Санкт-Петербург
4 999 0000000 0009999 10000 Тест Оператор Атлантида
+8 -20
View File
@@ -4,26 +4,6 @@
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
iconify
# lead-region-resolution spec/plan (DaData + Россвязь, 2026-05-29)
dadata
rossvyaz
unmappable
mnp
incrby
deyatelnost
resurs
numeracii
vypiska
reestra
sistemy
plana
маппингах
реконсиляция
сетап
хелперы
регэкспом
резолвом
# Бренд и термины проекта
лидерра
liderra
@@ -1994,3 +1974,11 @@ monitorится
guillemets
mirror'ящий
plan'овский
# Lead region resolution (2026-05-31) — DaData / Rossvyaz region detection
rossvyaz
россвязь
россвязи
dadata
kopecks
qc
+55 -1
View File
@@ -2,7 +2,61 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.39, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.40, консолидированная — разворачивает БД с нуля).
## v8.40 (2026-05-31) — lead region resolution (phone_ranges + resolution_log + supplier_leads/deals columns)
Резолюция настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback)
и переключение `LeadRouter` на каскадную маршрутизацию по региону. Эта запись покрывает
только схемные изменения Session 1 (таблицы и колонки); бизнес-логика — в последующих сессиях.
Спека: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
План: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
Миграция: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`.
**Добавлено:**
- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (SaaS-level, без RLS).
Поля: `source_url`, `rows_inserted`/`rows_updated`, `checksum_sha256`, `status`
(`in_progress`/`completed`/`failed`/`rolled_back`), `error`, `completed_at`.
GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
- **`phone_ranges`** — реестр диапазонов нумерации Россвязи (SaaS-level, без RLS — публичные данные).
Поля: `def_code` (код ABC/DEF), `from_num`/`to_num`, `operator`, `region`, `region_normalized`,
`subject_code` (1..89), `imported_at`, `import_id``phone_ranges_imports`. 3 CHECK
(`def_code` 300..999, `subject_code` 1..89, `from_num``to_num`). Индекс
`idx_phone_ranges_lookup (def_code, from_num, to_num)`. GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
- **`lead_region_resolution_log`** — PARTITION BY RANGE (`received_at`), composite PK
`(id, received_at)`. Аудит резолва региона на лид: `phone_masked`, `subject_code_resolved`/
`subject_code_from_tag`, `region_source` (`dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`/
`dadata_provider`/`dadata_type`/`dadata_response_masked` (JSONB), `rossvyaz_matched`,
`actual_subject_code`/`substituted_subject_code` (1..89), `routing_step` (1..3),
`phone_operator`, `cache_hit`, `duration_ms`, `resolved_at`. Индексы `idx_lrrl_lead_id` +
`idx_lrrl_source (region_source, received_at)`. GRANT SELECT,INSERT `crm_supplier_worker` /
SELECT `crm_app_user`. Стартовые партиции `lead_region_resolution_log_y2026_m05`, `_y2026_m06`.
- **`MonthlyPartitionManager::PARTITIONED_TABLES`** +entry `'lead_region_resolution_log' => 'received_at'`.
- **`system_settings`** +key `partition_retention_months_lead_region_resolution_log = '12'` (retention ~365 дней).
**Изменено:**
- **`supplier_leads`** +4 колонки: `resolved_subject_code` (CHECK 1..89), `region_source`
(CHECK `dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`, `phone_operator`. Persistent-idempotency
резолва (retry не повторяет DaData-вызов).
- **`deals`** +2 колонки: `phone_operator`, `region_substituted` BOOLEAN NOT NULL DEFAULT FALSE
(флаг подмены региона на запасном канале — `routing_step` 3).
**NB консолидация:** как и v8.39 (`project_routing_snapshots`), полный DDL живёт в дельта-миграции,
а не в теле `schema.sql` — тело отражает последнюю точку консолидации, заголовок/CHANGELOG ведут
дельты. Свежий деплой: миграция `0001` грузит `schema.sql` → дельта-миграция `2026_05_31` добавляет
эти объекты. Иначе был бы двойной `CREATE TABLE` (0001 + дельта) и `migrate` упал бы.
**NB GRANT'ы:** план Task 1.3 указывал `crm_readonly`, но этой роли на dev/прод нет —
фактические GRANT'ы выданы `crm_app_user` + `crm_supplier_worker` (проверено по `pg_roles`).
**NB 152-ФЗ:** `phone_masked` в логе — маскированный телефон (`7XXX***YYYY`), `dadata_response_masked`
хранит ответ DaData без сырого номера (spec §7.1). Полное `pg_anonymizer`-маскирование —
шаг раскатки (spec §7.2), вне Session 1.
---
## v8.39 (2026-05-27) — project_routing_snapshots (Slepok routing Этап 2)
+2 -1
View File
@@ -1,6 +1,7 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39))
-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
-- Базовая версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
-- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
+1 -4
View File
@@ -1,10 +1,7 @@
{
"2026-05": {
"WIN_USER_PATH": 206,
"WIN_USER_PATH": 123,
"IPV4": 1,
"RU_PHONE": 1
},
"2026-06": {
"WIN_USER_PATH": 91
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"last_read_at": "2026-05-30T12:32:49.927Z",
"read_count_last_period": 6,
"last_read_at": "2026-05-27T00:53:33.490Z",
"read_count_last_period": 5,
"period_start": "2026-05-19T00:00:00+03:00"
}
+24 -18
View File
@@ -1,21 +1,21 @@
# Brain Status (auto-generated)
Last updated: 2026-06-08T14:07:33.978Z
Last updated: 2026-06-02T10:14:43.123Z
| Контролёр | Состояние | Детали |
|---|---|---|
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 1 week(s) ago |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ✅ | 666 episode(s) this month · Stop-hook + post-commit OK |
| C5 Observer-coverage | ✅ | 137 episode(s) this month · Stop-hook + post-commit OK |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 666 episodes this month, 0 observer_error markers, 88 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 666
- Last /brain-retro: 9 day(s) ago
- Observer evidence: 137 episodes this month, 0 observer_error markers, 6 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 137
- Last /brain-retro: 2 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -24,14 +24,14 @@ Baseline дисциплины роутера (этап 2 router discipline overh
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
| planning | 96 | 10.4% | 13.5% |
| analysis | 33 | 6.1% | 0.0% |
| bugfix | 26 | 15.4% | 19.2% |
| feature | 24 | 12.5% | 4.2% |
| planning | 16 | 0.0% | 0.0% |
| feature | 4 | 0.0% | 0.0% |
| analysis | 2 | 0.0% | 0.0% |
| bugfix | 1 | 0.0% | 0.0% |
Router step distribution: 1: 321, 2: 261, 3: 18, 5: 55
Router step distribution: 1: 81, 2: 51, 5: 4
Boundaries applied (ADR / границы): 7 of 655 эпизодов (1.1%).
Boundaries applied (ADR / границы): 1 of 136 эпизодов (0.7%).
## Активные многоэтапные проекты
@@ -43,16 +43,22 @@ Boundaries applied (ADR / границы): 7 of 655 эпизодов (1.1%).
## Длинные сессии
Ни одной сессии с >50 ходов сегодня (UTC). ✅
⚠️ Сегодня (2026-06-02 UTC) есть сессии с 50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
| session_id | макс. ход | % regulated | последний эпизод |
|---|---|---|---|
| `1a9888f8` | 50 | 0% | 2026-06-02T01:43:02.824Z |
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.
## Стоимость месяца
| Компонент | Токены (in/out) | USD |
|---|---|---|
| Classifier (Sonnet 4.6) | 41653/183234 | $2.87 |
| Classifier (Sonnet 4.6) | 10473/50827 | $0.79 |
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
| **Итого** | | **$2.87** |
| **Итого** | | **$0.79** |
## Аномалии классификатора
@@ -65,7 +71,7 @@ Episodes since last run: 542 / threshold: 10
## Reviewer: субагент vs fallback
0 эпизодов проверено из 666.
0 эпизодов проверено из 137.
## Reviewer findings
@@ -91,8 +97,8 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3916 | MsMpEng | 1.99ч | NaNч |
| 15260 | Code | 1.71ч | 0.0ч |
| 10388 | Code | 3.05ч | 1327306.2ч |
| 3220 | MsMpEng | 1.14ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
File diff suppressed because one or more lines are too long
@@ -15,13 +15,11 @@
## File Structure
**Create:**
- `app/app/Services/Audit/AuditChainConfig.php` — shared конфиг 6 audit-таблиц (columns + partition_clause). Public const `TABLES`. Helper `rowExpression(string $table): string` для построения `ROW(...)` выражения.
- `app/tests/Unit/Services/Audit/AuditChainConfigTest.php` — unit-тесты на конфиг (полнота 6 таблиц, корректность ROW expression).
- `docs/incidents/2026-06-XX-activity-log-y2026-m05-cleanup-handoff.md` — handoff для прод-выкатки финального cleanup'а (Task 7).
**Modify:**
- `app/app/Console/Commands/VerifyAuditChains.php:98-238` — заменить private `TABLE_CONFIG` const на чтение из `AuditChainConfig::TABLES`. Поведение не меняется (regression-safe refactor).
- `app/app/Console/Commands/AuditRebuildChain.php:40-218` — заменить private `COLUMN_CONFIG` на `AuditChainConfig`, переписать `handle()` SQL под per-partition_clause logic (через `LAG OVER`).
- `app/tests/Feature/Audit/AuditRebuildChainTest.php` — добавить 3 новых сценария (multi-tenant / BYPASSRLS table / single-row partition); существующие тесты должны продолжать проходить.
@@ -32,7 +30,6 @@
### Task 1: Создать shared AuditChainConfig
**Files:**
- Create: `app/app/Services/Audit/AuditChainConfig.php`
- Test: `app/tests/Unit/Services/Audit/AuditChainConfigTest.php`
@@ -217,7 +214,6 @@ git commit -m "feat(audit): extract AuditChainConfig shared TABLE config (ADR-01
### Task 2: Перевести VerifyAuditChains на shared config (regression-safe refactor)
**Files:**
- Modify: `app/app/Console/Commands/VerifyAuditChains.php:96-238` (заменить private const на чтение `AuditChainConfig::TABLES`)
- Test: `app/tests/Feature/Audit/AuditChainRaceConditionTest.php` (existing — должен продолжать проходить)
@@ -275,7 +271,6 @@ git commit -m "refactor(audit): VerifyAuditChains использует shared Au
### Task 3: Failing tests для per-tenant rebuild
**Files:**
- Modify: `app/tests/Feature/Audit/AuditRebuildChainTest.php` (add 3 scenarios — multi-tenant / BYPASSRLS / single-row)
- [ ] **Step 1: Добавить multi-tenant test (failing)**
@@ -397,7 +392,6 @@ git commit -m "test(audit): failing tests для per-tenant rebuild (ADR-018, RE
### Task 4: Реализовать per-tenant rebuild через LAG OVER
**Files:**
- Modify: `app/app/Console/Commands/AuditRebuildChain.php` (целиком переписать `handle()` + удалить `COLUMN_CONFIG` + использовать `AuditChainConfig`)
- [ ] **Step 1: Переписать AuditRebuildChain**
@@ -573,7 +567,6 @@ git commit -m "fix(audit): AuditRebuildChain per-tenant LAG OVER (ADR-018, close
### Task 5: Активировать ADR-018 Enforcement rule
**Files:**
- Modify: `docs/adr/ADR-018-audit-chain-per-tenant-semantics.md` (Enforcement-блок — снять «активируется после имплементации» note + проверить что rule срабатывает)
- [ ] **Step 1: Обновить Enforcement-блок**
@@ -654,7 +647,6 @@ git commit -m "style(audit): pint auto-fix на shared config + rebuild rewrite"
### Task 7: Handoff для прод-выкатки cleanup'а activity_log_y2026_m05
**Files:**
- Create: `docs/incidents/2026-05-29-audit-rebuild-per-tenant-cleanup-handoff.md`
- [ ] **Step 1: Создать handoff-док**
@@ -5,7 +5,6 @@
**Goal:** Remove 5 obsolete v3.9 enforcement hooks and register all 12 active router-gate v4 hooks in `.claude/settings.json` in block-mode, creating 5 thin wrappers for pure modules that still need them.
**Architecture:** Three layers:
1. **Pure modules** in `tools/<name>.mjs` — already created by streams A-E.
2. **Thin `enforce-<name>.mjs` wrappers** — stdin event → pure module `decide()``exitDecision`. Pattern lifted from existing `tools/enforce-router-gate.mjs:183-204`.
3. **`.claude/settings.json` registration** — matcher + command path + timeout. Block-mode means `exitDecision({ block: true })` exits with code 2 stopping the originating tool call.
@@ -13,7 +12,6 @@
**Tech Stack:** Node.js ESM (`.mjs`), `vitest` (jsdom env), `lefthook` pre-commit, `.claude/settings.json` schema `https://json.schemastore.org/claude-code-settings.json`.
**Reference helpers** (already present in `tools/enforce-hook-helpers.mjs`):
- `readStdin()` — read PreToolUse/PostToolUse/Stop event JSON.
- `parseEventJson(raw)` — safe JSON.parse with `{}` fallback.
- `readTranscript(event.transcript_path)` — load JSONL.
@@ -112,7 +110,6 @@ Expected: line with sha + `refs/heads/backup-pre-v4-cleanup`.
The vocab-based override system is fully removed in v4 (universal vocab removal per spec §4.2). Existing call sites in deleted hooks go away in Task 7; other callers (`enforce-verify-before-push.mjs`, `enforce-tdd-gate.mjs`, `enforce-memory-coverage.mjs`, `enforce-branch-switch.mjs`) still import `findOverride` / `findOverrideAttempt` / `loadOverrideVocab`. We keep these symbols as permanent stubs so non-deleted hooks keep building.
**Files:**
- Modify: `tools/enforce-hook-helpers.mjs:197-249` (functions `loadOverrideVocab`, `findOverride`, `findOverrideAttempt`)
- Modify: `tools/enforce-hook-helpers.test.mjs` (drop vocab-dependent assertions, add stub contract tests)
@@ -202,7 +199,6 @@ Wraps `tools/todowrite-skill-verifier.mjs::verifyClaims + hardSyncCheck`. Fires
**Pattern reference:** `tools/enforce-router-gate.mjs:183-207` (main() shape, fail-CLOSE behaviour). For Stop hooks we use fail-open (`block: false`) because false-positive Stop block would freeze sessions.
**Files:**
- Create: `tools/enforce-todowrite-skill-verifier.mjs`
- Create: `tools/enforce-todowrite-skill-verifier.test.mjs`
@@ -337,7 +333,6 @@ git commit -m "feat(router-gate-v4): enforce-todowrite-skill-verifier (Stop hook
Wraps `tools/tdd-real-test-verifier.mjs::verifyRealTest`. Fires on Edit/Write of a `*.test.*` or `*.spec.*` file. If the test content lacks `expect(...)` / `it(...)` / `test(...)` or covers none of the prod files edited in this session, blocks.
**Files:**
- Create: `tools/enforce-tdd-real-test-verifier.mjs`
- Create: `tools/enforce-tdd-real-test-verifier.test.mjs`
@@ -484,7 +479,6 @@ git commit -m "feat(router-gate-v4): enforce-tdd-real-test-verifier (PreToolUse
Wraps `tools/self-debrief-detector.mjs::detectSelfDebrief`. Fires on mutating tools (Edit|Write|MultiEdit|Bash). Reads transcript; if last controller text matches self-debrief patterns and no `self-retrospect` / `brain-retro` Skill invoked recently — block.
**Files:**
- Create: `tools/enforce-self-debrief-detector.mjs`
- Create: `tools/enforce-self-debrief-detector.test.mjs`
@@ -612,7 +606,6 @@ Wraps `tools/mcp-tool-classifier.mjs::classifyMcpTool`. Fires on any `mcp__*` to
**Pre-step:** Inspect `tools/mcp-tool-classifier.mjs` exported function names (`classifyMcpTool` vs `classify` vs other) — adjust import below if name differs.
**Files:**
- Create: `tools/enforce-mcp-classification.mjs`
- Create: `tools/enforce-mcp-classification.test.mjs`
@@ -716,7 +709,6 @@ Wraps `tools/decomposition-detector.mjs::detectDecomposition`. Fires on mutating
**Pre-step:** Inspect `tools/decomposition-detector.mjs` for the actual function name and signature; adapt below.
**Files:**
- Create: `tools/enforce-decomposition-detector.mjs`
- Create: `tools/enforce-decomposition-detector.test.mjs`
@@ -823,7 +815,6 @@ git commit -m "feat(router-gate-v4): enforce-decomposition-detector (PreToolUse
## Task 7: Delete 5 v3.9 hooks and the vocab file
**Files (delete):**
- `tools/enforce-chain-recommendation.mjs`
- `tools/enforce-chain-recommendation.test.mjs`
- `tools/enforce-classifier-match.mjs`
@@ -856,7 +847,6 @@ git rm tools/enforce-override-vocab.json
- [ ] **Step 3: Run full vitest tools suite (must still pass — no orphan references)**
Run:
```
npx vitest run tools/ \
--exclude='**/worktrees/**' \
@@ -864,7 +854,6 @@ npx vitest run tools/ \
--exclude='**/subagent-prompt-prefix*' \
--exclude='**/llm-judge.integration*'
```
Expected: all PASS. If any failure references a deleted file — it's a stale import; fix that file by removing the dead import.
- [ ] **Step 4: Commit**
@@ -886,13 +875,11 @@ Deleted hooks superseded by v4 architecture (spec §4 behavioural pivot):
## Task 8: Update `.claude/settings.json` — remove 5 v3.9 regs, add 12 v4 regs in block-mode
**Files:**
- Modify: `.claude/settings.json`
**Plan:** Read the current file (already done at planning time — see baseline below), then apply edits via multiple `Edit` tool calls because `settings.json` is JSON (no comments allowed) and the changes are scattered across the `hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.Stop` arrays.
**Baseline (current state, lines 39262):** five v3.9 hook blocks present at:
- PreToolUse[3] (lines 6978) — `enforce-chain-recommendation` — REMOVE
- PreToolUse[4] (lines 7988) — `enforce-override-limit` — REMOVE
- PreToolUse[7] (lines 119128) — `enforce-semgrep-security` — REMOVE
@@ -1058,7 +1045,6 @@ Stream G of router-gate v4 deployment, last step before user-run smokes."
- [ ] **Step 1: Full vitest tools suite**
Run:
```
npx vitest run tools/ \
--exclude='**/worktrees/**' \
@@ -40,7 +40,6 @@
### Task 1: RED tests for skill-body skip + negative tests for non-skill `isMeta`
**Files:**
- Modify: `tools/enforce-hook-helpers.test.mjs` — add 3 cases at end of `describe('lastTurnEntries / ...')` block.
- [ ] **Step 1:** Add a new `it()` block "lastTurnEntries skips skill body injections (isMeta + sourceToolUseID)" that constructs an entries array `[user-prompt, assistant+SkillToolUse, skillBody(isMeta=true, sourceToolUseID), assistant+follow-up]` and asserts `lastTurnEntries(entries)` returns starting from `user-prompt` (NOT from skill body).
@@ -54,7 +53,6 @@
### Task 2: Implement skill-body skip in lastTurnEntries
**Files:**
- Modify: `tools/enforce-hook-helpers.mjs` lines 100-115 (`lastTurnEntries` body).
- [ ] **Step 1:** In the back-walk loop, before checking `e.message.role === 'user'`, add: `if (e && e.isMeta === true && typeof e.sourceToolUseID === 'string') continue;` — this skips skill-body injections (isMeta + tool-spawned) while keeping all other `isMeta:true` cases as valid turn boundaries.
@@ -66,7 +64,6 @@
### Task 3: Commit
**Files:**
- Commit message in `.scratch/sibling-lastturn-fix-msg.txt`.
- [ ] **Step 1:** Pre-write approval records for:
@@ -0,0 +1,118 @@
# Lead Region Resolution — прогресс автономного прогона (ночь 31.05.2026)
> Хендофф после автономной ночной сессии. Вся работа **на диске в worktree
> `worktree-feat+lead-region-resolution`, НЕ закоммичена** (git commit/push требуют
> approval владельца через гейт — владелец спал). Утром: ревью → коммиты → продолжение.
## Что сделано (Сессии 1–4 — весь движок резолва региона, TDD-зелёный)
| Сессия | Статус | Тесты |
|---|---|---|
| **1** Схема (миграция + партиции + schema.sql sync) | ✅ на диске | 9 passed / 27 assert |
| **2** Россвязь (lookup + DTO + import-команда) | ✅ на диске | 9 passed / 27 assert |
| **3** DaData (region map + config + enum + client + budget guard) | ✅ на диске | 16 passed / 119 assert |
| **4** LeadRegionResolver (оркестратор, 16 кейсов каскада) | ✅ на диске | 16 passed / 46 assert |
| **Консолидированная регрессия** (все файлы вместе) | ✅ | **53 passed / 238 assert** |
### Новые/изменённые файлы
**Создано:**
- `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
- `app/app/Services/RossvyazPrefixLookup.php` + `app/app/Services/Dto/RossvyazRecord.php`
- `app/app/Console/Commands/PhoneRangesImportCommand.php`
- `app/app/Support/DaDataRegionMap.php`
- `app/app/Services/DaData/{DaDataQualityCode,DaDataException,DaDataTimeoutException,DaDataPhoneResponse,DaDataPhoneClient,DaDataBudgetGuard}.php`
- `app/app/Services/Dto/RegionResolution.php`
- `app/app/Services/LeadRegionResolver.php`
- Тесты: `tests/Feature/Migrations/PhoneRangesMigrationTest.php`, `tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`, `tests/Feature/Services/RossvyazPrefixLookupTest.php`, `tests/Feature/Console/PhoneRangesImportCommandTest.php`, `tests/Unit/Support/DaDataRegionMapTest.php`, `tests/Feature/Services/DaData/{DaDataPhoneClientTest,DaDataBudgetGuardTest}.php`, `tests/Feature/Services/{RegionResolutionTest,LeadRegionResolverTest}.php`
- `tests/Fixtures/rossvyaz/sample.csv`
**Изменено:**
- `app/app/Services/MonthlyPartitionManager.php` — +entry `'lead_region_resolution_log' => 'received_at'`
- `app/app/Models/SupplierLead.php` — +4 колонки в fillable + 2 int-cast
- `app/config/services.php` — +блок `dadata`
- `app/tests/Feature/PartitionsCreateMonthsTest.php` — хрупкий хардкод «48 skipped» → динамический `count(PARTITIONED_TABLES) * 6`
- `db/schema.sql` (v8.39 → **v8.40**, только заголовок) + `db/CHANGELOG_schema.md` (+v8.40)
## Решения, принятые по ходу (для ревью)
1. **Коды субъектов** — по `RussianRegions` (Москва=82, СПб=83, МО=56, ЛО=53), НЕ по спеке (там были авто-коды 77/78/50/47 — неверно).
2. **GRANT'ы миграции**`crm_app_user` + `crm_supplier_worker` (роли `crm_readonly` из плана **не существует**).
3. **`schema.sql`** — только заголовок + CHANGELOG, без тела (как v8.39 project_routing_snapshots): иначе двойной `CREATE TABLE` (0001 грузит schema.sql + дельта-миграция) сломал бы `migrate`.
4. **Размещение тестов** — app/DB-зависимые тесты (DaData-клиент, budget, resolver, DTO с моделью) лежат в **`tests/Feature/...`, не `tests/Unit/...`** как в плане: в проекте `tests/Unit` не бутит Laravel (нет `Http::fake`/`app()`/`Cache`). Чистый `DaDataRegionMap` остался в Unit.
5. **`PhoneRangesImportCommand` swap** — atomic RENAME реализован по спеке, но **committing-swap НЕ покрыт автотестом** (RENAME коммитит и сломал бы общую `liderra_testing`, которую ночью без терминала владельца не пересоздать). Тесты покрывают parse/map/dry-run/idempotency/force. **Свап проверяется первым реальным импортом оператора (Session 6 runbook).** Косметика: lookup-индекс на новой таблице после свапа носит имя `idx_phone_ranges_staging_lookup` (имя `idx_phone_ranges_lookup` занято `phone_ranges_old`).
6. **DaData call cost**`services.dadata.call_cost_kopecks` дефолт 60 (≈0.60 ₽/вызов) — **прикидка, откалибровать по тарифу DaData**.
7. **CSV-парсер импорта** — нативный `str_getcsv(';')` (как проект читает файлы); реальный формат Россвязи (заголовки `АВС/ DEF;От;До;Емкость;Оператор;Регион`, возможно cp1251) уточняется оператором на реальном пакете. XLSX-ветка через openspout — **не протестирована**.
## Что осталось (требует владельца)
### Коммиты (утром, через git-approval)
Предлагаемая разбивка (conventional commits, ветка `worktree-feat+lead-region-resolution`):
- `feat(region): schema migration + MonthlyPartitionManager registration` (миграция, partition manager, PartitionsCreateMonths fix, SupplierLead model, тесты Session 1)
- `chore(region): sync db/schema.sql + CHANGELOG (v8.40)`
- `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`
- `feat(region): phone-ranges:import command (parse/map/dry-run/idempotency)`
- `feat(region): DaData layer (region map, config, enum, client, budget guard)`
- `feat(region): LeadRegionResolver orchestrator (full qc cascade)`
> NB: коммит-сообщения **без** trailer `Co-Authored-By` — гейт блокирует символ `<` (угловые скобки email). Зафиксировано в `docs/bugs.md`.
### D1 — продуктовое решение ДО Session 5
Сейчас при >3 кандидатах лид раздаётся **3 случайным** клиентам. Каскад (Session 5) раздаёт 3 клиентам с **наибольшим остатком дневного лимита** (детерминированно) — клиент с большим остатком систематически получает больше лидов. Каскад по конструкции (роутер режет до 3 упорядоченно → `LeadDistributor` не шаффлит) **и есть** эта смена. Нужно подтверждение: убрать random — ок? (Если хочешь сохранить случайность внутри региона — это +1 задача: shuffle внутри каждой фазы перед cap.)
### Session 5 (каскад LeadRouter) + Session 6 (интеграция в Job) — после D1
- Зависят от D1 + трогают прод-критичный `RouteSupplierLeadJob` (30k лидов/сутки) → делать с ревью, не вслепую.
- Session 6 Task 6.4 (smoke-команда `phone-region:smoke`) + метрики §8 — отдельно.
### Pre-existing tech debt (не моё, флагую)
- `tests/Feature/Import/MonthlyPartitionManagerTest.php::ensureMonth создаёт партицию webhook_log`**красный независимо от меня**: `webhook_log` удалён из проекта 24.05 (миграция `2026_05_24_140000`), тест не обновили. Можно убрать как наследие отдельным мелким фиксом — на твоё усмотрение.
- `migrate:fresh` на проекте **сломан** (cross-PDO `auth_log` в миграции `0001`): миграция грузит schema.sql на `pgsql`, затем зовёт `partitions:create-months` на `pgsql_supplier` в той же транзакции → невидимость. Тестовая база `liderra_testing` собрана клоном dev (`CREATE DATABASE ... WITH TEMPLATE liderra`), а не через migrate:fresh. Отдельная проблема, вне фичи.
## Как прогнать (из `app/`)
```
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php
```
→ 53 passed / 238 assertions.
---
## ОБНОВЛЕНИЕ 01.06.2026 — Сессии 5–6 реализованы, фича функционально завершена
**D1 решён заказчиком — вариант В** (взвешенный жребий по остатку лимита; мелкие клиенты не отрезаются, вес ≥ 1 у каждого).
| Сессия | Что сделано | Тесты |
|---|---|---|
| **5** LeadRouter каскад (exact→all-RF→fallback) + взвешенный жребий (В) + `routing_step` | `LeadRouter` переписан: `matchEligibleProjects($sp, ?int $resolvedSubjectCode)`, `queryCandidates` (region-фильтр + `snap.regions`), `weightedPick`, инъекция `Randomizer`. Хелпер `createRoutingSnapshotFromProject(+regions)`. | 9 cascade + 10 regression |
| **6.1** Резолв до tx + persist + лог в `RouteSupplierLeadJob` | `app(LeadRegionResolver)->resolve()` (НЕ 7-й параметр handle — чтобы не ломать сигнатуру/тесты), persist 4 колонки, `logRegionResolution` (fail-safe INSERT в журнал через pgsql_supplier, маскированный телефон). | в наборе из 8 |
| **6.2** Подмена subject_code на шаге 3 + `region_substituted` | `createDealCopyForProject(RegionResolution)`, `routing_step` захватывается до `$lockedProject`, `pickSubstituteRegion(snapshot.regions)`. Deal +`phone_operator`/`region_substituted` (model fillable+cast). | в наборе из 8 |
| **6.3** CSV-merge по рангу источника | merge-блок обновляет subject_code/phone_operator если webhook-резолв dadata/rossvyaz (выше tag CSV). **Эвристика**`deals.region_source` нет (документировано). | 2 |
| **6.4** Smoke-команда `phone-region:smoke` | резолв по телефону без записи в БД. **Метрики §8.1 отложены** (нет механизма Prometheus/StatsD в проекте). | 2 |
| **6.5** Финальная регрессия + runbook | **101 passed / 509 assertions** (вся фича + регрессия Job ×3 / Router ×2). Runbook раскатки: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`. | 101 |
### Новые/изменённые файлы Сессий 5–6 (в worktree, не закоммичено)
- Изменено: `app/app/Services/LeadRouter.php` (каскад + weighted pick + Randomizer), `app/app/Jobs/RouteSupplierLeadJob.php` (resolve+persist+log+substitution+CSV-merge), `app/app/Models/Deal.php` (+2 fillable, +1 cast), `app/tests/Pest.php` (helper +regions).
- Создано: `app/app/Console/Commands/PhoneRegionSmokeCommand.php`; тесты `LeadRouterCascadeTest.php`, `RouteSupplierLeadJobRegionResolutionTest.php`, `PhoneRegionSmokeCommandTest.php`; runbook.
### Решения Сессий 5–6 (для ревью)
1. **D1=В** — взвешенный жребий, мелкие не отрезаны (доказано тестом `variant В: weighted pick` — 120 seed'ов, мелкий выигрывает >0 раз, крупный чаще).
2. **LeadRegionResolver через `app()` внутри `handle()`**, не 7-м параметром — иначе ломались бы сигнатура + 3 существующих Job-теста.
3. **Лог резолва fail-safe** — сбой записи аудит-лога не роняет доставку лида (30k/сутки).
4. **`deals.region_source` НЕ добавлялась** — CSV-merge по рангу через эвристику (dadata/rossvyaz > CSV-tag). Отклонение от плана Task 6.3 (план предполагал колонку), задокументировано.
5. **Метрики §8.1 отложены** — нет механизма метрик в проекте.
### Коммиты Сессий 5–6 (предложение, ветка `worktree-feat+lead-region-resolution`)
- `test(region): createRoutingSnapshotFromProject accepts regions param`
- `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) + weighted pick variant В + routing_step`
- `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist + fail-safe log`
- `feat(region): step-3 region substitution + CSV-merge by source rank`
- `feat(region): phone-region:smoke staging command`
- `docs(region): rollout runbook + session progress`
### Пре-существующий долг (флагую, не моё)
- `tests/Feature/Console/{BillingMigrateLeadsToRub,IncidentsWatchFailures,SnapshotBackfillCommand}Test`**взаимно загрязняются** при прогоне в одном процессе (счётчики растут: ожидал 1, получил 4-5). Падают и БЕЗ моих файлов. В реальном CI (`pest --parallel`, файл = процесс) проходят. Тест-изоляция этих команд хрупкая — отдельная задача.
### Команда финальной регрессии (явный список, из `app/`)
```
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Feature/Console/PhoneRegionSmokeCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
```
→ 101 passed / 509 assertions.
@@ -0,0 +1,70 @@
# 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`, не трогать миграции/чужие файлы).
@@ -0,0 +1,471 @@
# 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).
@@ -0,0 +1,81 @@
# Lead Region Resolution — runbook раскатки на прод
> Фича: определение настоящего региона лида по телефону (DaData → реестр Россвязи →
> tag-fallback) + каскадная маршрутизация по региону. Код реализован и зелёный
> (Сессии 1-6, TDD). Этот runbook — порядок выкатки оператором на `liderra.ru`.
> Spec: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
> Plan: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
## Решение D1 (зафиксировано заказчиком 01.06.2026)
**Вариант В** — внутри каждой ступени каскада при >3 претендентах лид раздаётся
**взвешенным жребием по остатку дневного лимита**: шанс ∝ остатку, но у каждого
кандидата шанс > 0 (вес ≥ 1) — маленькие клиенты не отрезаются. Реализовано в
`LeadRouter::weightedPick` (вес `max(1, snapshot_daily_limit delivered_today)`).
## Предусловия
- `DADATA_API_KEY` + `DADATA_SECRET` — завести в **YC Lockbox** (НЕ в git/.env репозитория).
Прокинуть в окружение прод-воркеров (`DADATA_API_KEY`, `DADATA_SECRET`).
- Feature-flag `LEAD_REGION_RESOLVER_ENABLED` (по умолчанию `false` → текущее tag-поведение).
- Бюджет: `DADATA_DAILY_CAP_RUB` (дефолт 10000), `DADATA_CALL_COST_KOPECKS` (дефолт 60 —
**откалибровать по фактическому тарифу DaData** после первого дня).
## Порядок выкатки
1. **Миграция БД.** Накатить `2026_05_31_100000_create_phone_ranges_and_resolution_log`
(создаёт `phone_ranges`, `phone_ranges_imports`, `lead_region_resolution_log` +
колонки в `supplier_leads`/`deals`). Партиции журнала на старте — m05/m06; далее
их подхватывает `partitions:create-months` (уже зарегистрирован в `MonthlyPartitionManager`).
- На проде миграция делает `SET ROLE crm_migrator` (паттерн проекта).
2. **Импорт реестра Россвязи.** Скачать пакет выписок с
`rossvyaz.gov.ru/deyatelnost/resurs-numeracii/...` (~500-600 файлов) в каталог,
затем `php artisan phone-ranges:import --dir=<каталог>`.
- **NB парсер:** ожидает CSV `;`-разделитель, колонки `АВС/ DEF;От;До;Емкость;Оператор;Регион`.
Реальные файлы Россвязи могут быть в cp1251 / иметь другие заголовки — сверить на
первом импорте; при расхождении поправить `resolveColumns()` (это и есть первая
боевая валидация — автотест покрывает CSV-фикстуру, не реальный формат).
- **NB swap:** atomic RENAME (`phone_ranges``_old`, staging → `phone_ranges`) НЕ
покрыт автотестом (коммитящий RENAME сломал бы общую тестовую БД). **Этот импорт —
первая боевая проверка свапа.** Сначала прогнать `--dry-run` (staging без свапа),
проверить `phone_ranges_staging` глазами, потом без `--dry-run`. Откат:
`phone-ranges:rollback` (см. spec §6.4 — команда отката пока не реализована,
при необходимости — ручной RENAME `phone_ranges_old` обратно).
3. **Деплой кода с `LEAD_REGION_RESOLVER_ENABLED=false`.** Резолвер выключен →
поведение идентично текущему (tag-fallback). Каскад работает (но без точного
региона, т.к. `resolved_subject_code=null` → шаг 2 «вся РФ» как раньше).
4. **Smoke на staging/проде:** `php artisan phone-region:smoke --phone=79161234567`
(с реальным ключом — платный вызов, в БД не пишет). Проверить, что DaData отвечает,
регион/оператор резолвятся, Россвязь-fallback находит префиксы. Прогнать §9.4 — ~100
реальных prod-номеров, сверить распределение источников.
5. **Включить флаг (сразу 100%):** `LEAD_REGION_RESOLVER_ENABLED=true`. Рубильник
глобальный — резолвер включается сразу для **всего** потока лидов. **Долевую
(постепенную) раскатку НЕ делаем** (решение заказчика 01.06.2026): никакого
`hash(phone) % 100`-гейта не вводим, фича идёт на 100% с первого включения.
6. **Мониторинг 1 день:** `lead_region_resolution_log` — распределение `region_source`
(ожидание: dadata большинство, tag < 20%, unknown < 5% — spec §8.2). Проверить
`DADATA_DAILY_CAP_RUB` не упирается. Откалибровать `DADATA_CALL_COST_KOPECKS`.
7. **Штатный режим:** фича уже работает на 100% потока (с шага 5) — долевого гейта нет,
убирать нечего. Единственный рычаг управления — флаг `LEAD_REGION_RESOLVER_ENABLED`.
8. **Ежемесячный cron** импорта реестра (`phone-ranges:import`, 4-е число 03:00 МСК —
spec §6.3) — добавить в планировщик/`artisan-run`.
## Откат
- Мгновенный: `LEAD_REGION_RESOLVER_ENABLED=false` → резолвер возвращает tag-fallback,
каскад ведёт себя как до фичи. Код деплоить заново не нужно.
- Реестр: `phone_ranges_old` хранит предыдущую версию (ручной RENAME при проблеме импорта).
## Что отложено (followups, не блокируют ядро)
- **Метрики §8.1** (`phone_resolution.source.*` и т.д.) — в проекте нет механизма
Prometheus/StatsD; отложено до его появления.
- **Долевая (постепенная) раскатка****НЕ делаем** (решение заказчика 01.06.2026):
фича включается сразу на 100%, `hash(phone)%100`-гейт не вводится.
- **`phone-ranges:rollback`** — команда отката свапа (spec §6.4) не реализована.
- **`deals.region_source`** — не добавлялась (по спеке регион-источник живёт на
`supplier_leads` + в журнале). CSV-merge (§3.12) обновляет регион сделки по
эвристике «webhook dadata/rossvyaz > CSV-tag», без хранения source на сделке.
- **pg_anonymizer-маски (§7.2)** на `lead_region_resolution_log` — при настройке масок дампов.
- **152-ФЗ:** телефон в журнале маскирован (`7XXX***YYYY`), `dadata_response_masked`
без сырого номера — базовое покрытие есть; полный аудит ПДн — через `pdn-152fz-audit`.
@@ -0,0 +1,149 @@
# Runbook — Имитация портала, Фаза 1 (репетиция у себя)
**Дата:** 0304.06.2026. **Ветка:** `worktree-prod-imitation-clients` (база `origin/main` `bd7b1d3e`).
**Спек:** `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
**План:** `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md`
Цель Фазы 1 — посмотреть на портал глазами клиента на копии (= боевой код) и поймать
логические ошибки до Фазы 2. Деньги — локальные, DaData — подставная/выключенная.
---
## 1. Сверка «копия = боевой» (Шаг 0)
| Что | Как проверить |
|---|---|
| Код | Копия на `origin/main` + ветка `worktree-prod-imitation-clients`. Региональная фича влита. |
| Схема БД | `db/schema.sql` (полная текущая) грузится миграцией `0001`; дельта-миграции идемпотентны. |
| Роли БД | На dev — `postgres` superuser; `pgsql_supplier` фоллбэчит на него (RLS/BYPASSRLS как на проде). |
| Справочники | `pricing_tiers` (7 ступеней, `PricingTierSeeder`), `phone_ranges` (по требованию), поставщики b1/b2/b3/direct (миграции). |
| Тест-БД | `liderra_testing` пересобирается `migrate:fresh` (см. ниже). |
### Пересборка тест-БД (если нужна чистая)
```bash
cd app
DB_DATABASE=liderra_testing DB_USERNAME=postgres DB_PASSWORD=liderra_dev_pass \
php artisan migrate:fresh --force
```
> **NB.** `migrate:fresh` чинится тремя вещами (коммит `22f6178b`): `MonthlyPartitionManager::ensureMonth`
> пропускает партиционированную таблицу без существующего родителя; миграция `0001` идёт с
> `$withinTransaction=false` (DDL коммитится до `partitions:create-months`); дельта-миграции
> `add_balance_freeze` / `add_paused_at` идемпотентны (`DROP POLICY IF EXISTS` / проверки колонки/индекса).
> **НИКОГДА не запускать `migrate:fresh` посреди работы субагентов** — это общая тест-БД.
---
## 2. Наполнить живой локальный портал (`imitation:seed`)
```bash
cd app
php artisan imitation:seed --leads=20 --clients=3
```
Команда (app-namespace, **запрещена на production**): создаёт несколько профинансированных
тестовых клиентов на общем источнике B2, выключает DaData (регион берётся из тега), пересобирает
снапшот за активную дату и инжектирует синтетические заявки через **настоящий**
`RouteSupplierLeadJob` — появляются сделки, списания, уведомления, как в проде.
Опции: `--leads=N` (число заявок), `--clients=N` (число клиентов).
---
## 3. Ручной проход глазами клиента (UI)
1. Логин / 2FA.
2. Проекты — список, карточка проекта.
3. Лента сделок — новые сделки (`status='new'`), карточка сделки (телефон, **Город** = настоящий
регион лида даже при подмене региона на шаге 3).
4. Смена статуса сделки.
5. Экспорт CSV / XLSX.
6. Баланс — текущий остаток, история списаний (`balance_transactions`).
7. Тарифы — текущая ступень (по `delivered_in_month`).
8. Колокольчик — уведомление о новой сделке; при нулевом балансе — письмо «нулевой баланс».
---
## 4. Наблюдение естественного цикла (время форсим)
- **Слепок:** смена настройки сегодня → эффект со следующего снапшота. Форсить:
`php artisan snapshot:rebuild --date=<активная_дата>` (DELETE+INSERT).
- **Сброс `delivered_today`** в 00:00 МСК — обнуление дневных счётчиков.
- **`CsvReconcileJob`** (ежечасно) — дрейф > 5% → алерт.
---
## 5. Шаблон отчёта «ожидали / получили / находки»
| Сценарий | Ожидали | Получили | Вывод |
|---|---|---|---|
| … | … | … | OK / находка |
---
## 6. Результаты Фазы 1 (заполнено)
### Автоматический набор сценариев — `php artisan test --group=imitation`
**Все 54 теста / 194 assertions — GREEN** (36с, изоляция через `DatabaseTransactions`).
Покрытие по плану:
| Группа | Файл | Результат |
|---|---|---|
| Кирпичи | FakeDaData / LeadInjector / SnapshotForge / ConditionLevers / Seeder | GREEN |
| Регион-каскад резолвера (§7 эт.1) | `RegionResolverCascadeTest` (11) | GREEN |
| A — взвешенный жребий + X2 | `ScenarioA_WeightedLotteryTest` | P0(300)→76% / P3,P4(3)→3 каждый (мелкий не отрезан) |
| B/C — каскад по региону | `ScenarioBC_RegionCascadeTest` (4) | exact step1 + all-RF step2; изоляция по регионам |
| D — дни доставки | `ScenarioD_DeliveryDaysTest` | неактивный сегодня отсутствует в снапшоте |
| E1/E2/F — заморозки + лимит | `ScenarioEF_FreezeLimitTest` (3) | auto-pause на InsufficientBalance; заморозка/лимит — фильтр роутера |
| G3 — осиротевшая заявка | `ScenarioG3_OrphanLeadTest` | оседает в `supplier_leads` (`processed_at`, `deals_created_count=0`, `error=NULL`) |
| G5/G6 — особые + дубли | `ScenarioG5G6_SpecialLeadsTest` (9) | qc→source; дедуп vid → 200 already_processed |
| X1/X3 — подмена + журнал | `ScenarioX1X3_SubstitutionJournalTest` (2) | step-3 подмена subject_code, Город=настоящий регион, журнал actual/substituted |
| Топологии + деньги + приём | `TopologyMoneyIntakeTest` (11) | G1/G2/G4 без утечек; деньги bcmath без копеек; приём 404/422/429 |
| Живая команда | `ImitationSeedCommandTest` | exit 0 + сделки создаются |
### Находки (главная ценность Фазы 1)
- **F1 (инфраструктура, починено `4dfcde99`):** `ImitationTestCase::seedPhoneRange()` использовал
несуществующие колонки (`range_from/range_to/region_name`) и не заполнял FK `import_id`
любой Россвязь-тест падал. Исправлено на реальные колонки `from_num/to_num/...` + anchor-импорт.
- **F2/F3 (план vs реальность, кода не трогали):** план §6.4/§7 говорит «qc=2/7 / флаг off → `tag`»,
по факту резолвер возвращает `unknown`, если тег пустой (`tag` — только когда тег = валидный
регион). Россвязь при qc=2/7 не зовётся — это верно. Тесты утверждают реальное поведение.
- **Денежная корректность (главная ставка) — чиста:** `lead_charges` (ступень/цена/`charge_source='rub'`),
`balance_transactions` (минус + остаток), `supplier_lead_costs`; списание bcmath без потери копеек;
цена по `delivered_in_month+1`; tier-граница (100 → ступень 2). Подтверждено `TopologyMoneyIntakeTest`.
- **Подмена региона на шаге 3 — корректна:** `deals.subject_code` = регион клиента, `deals.city` =
имя НАСТОЯЩЕГО региона лида, журнал `actual_subject_code`/`substituted_subject_code`, `routing_step=3`.
- **Осиротевший лид «невидим»:** ищется только запросом
`supplier_leads WHERE deals_created_count=0 AND processed_at IS NOT NULL AND error IS NULL`
(не в `failed_webhook_jobs`). Стоит иметь админ-вид «непроданные лиды».
- **Инжект через payload:** `RouteSupplierLeadJob` ре-резолвит supplier из `raw_payload['project']`
по `(platform, unique_key)` (`parseProjectField``resolveOrStub`); `unique_key` должен быть
доменом, чтобы распарситься как `site`. Учтено в `LeadInjector` и `imitation:seed`.
- **Worktree-env (не код):** `app/.env` в этом worktree содержит APP_KEY неверной длины →
существующий `SupplierWebhookTest` и шифрование падают; тесты, которым нужно шифрование, чинят
ключ в `beforeEach`. Это проблема окружения копии, не прода.
### Проектная регрессия (имитация ничего не сломала)
- `composer test` (single-process): 22 падения — **пре-существующее single-process загрязнение**,
не из имитации. Подтверждено: `ProjectExtensionsTest` 6/6 и `SupplierProjectTest` 6/6 **в изоляции**
зелёные; `IncidentsWatchFailures` — задокументированный polluter (CLAUDE.md). Малые count'ы (4/5),
не 36+ → имитационные тесты не текут (rollback `DatabaseTransactions` работает).
- `pest --parallel` (CI-режим, где загрязнение исчезает) — **в этом Windows-worktree падает на
bootstrap paratest** (container error, env-проблема). На CI/Linux — штатный режим.
- Прод-правки имитации (clean migrate:fresh fix) verified: `MonthlyPartitionManagerTest` 15/15 +
`migrate:fresh` проходит полностью.
- Фронтенд имитацией не затронут.
---
## 7. Хвосты (открыты, не блокируют Фазу 1)
- Чистка bootstrap-`beforeEach` в `app/tests/Feature/Imitation/SeederTest.php` (теперь no-op после
восстановления БД — можно упростить до обычного паттерна).
- Удаление пароля БД из `app/phpunit.xml` (вынести в `.env.testing`; ⚠️ `.env.testing` грузится
Laravel ВМЕСТО `.env` → нужен полный env, а не только DB-creds; проверить fallback на untracked `.env`).
- Прод-выкатка региональной фичи / Фаза 2 — отдельно.
@@ -0,0 +1,211 @@
# Дизайн: имитация работы портала глазами клиента — ФАЗА 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`).
-10
View File
@@ -146,16 +146,6 @@ const SAFE_EXACT = [
// → cwd-shift read-bypass stays contained (protected files also remain blocked by name
// in the command). cd into Документация/system/protected dirs → default-deny.
/^cd\s+(?=.*[\\/](?:worktree-|v4-stream-))(?!.*(?:\.\.|\.claude|\.ssh|\.env|runtime|\.git)).+$/,
// graphify read-only subcommands (#86, §5 п.14, owner-authorized 2026-06-08).
// Only query/explain/path — extract/update/build/export/hook/clone/add/merge stay
// default-deny. The bare \b form is safe: injection vectors are neutralized BEFORE the
// whitelist sees them — chains split into per-segment whitelist checks (an injected
// `; id` segment is not whitelisted → block), subshells `$(...)`/backtick are blocked by
// the tokenizer, redirects by the hard-blacklist, and $VAR is var-expanded by the
// tokenizer (not an injection vector for a read-only query arg). End-anchoring with a
// charset would reject Unicode query strings (tokenizer strips quotes → Cyrillic args
// arrive as barewords) for no security gain. (security review 2026-06-08 — false-positive)
/^graphify\s+(?:query|explain|path)\b/,
];
export function classifyWhitelist(segments) {
-33
View File
@@ -241,39 +241,6 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
expect(classifyBashCommand('composer show', {}).result).toBe('allow');
expect(classifyBashCommand('composer outdated', {}).result).toBe('allow');
});
// graphify read-only subcommands (owner-authorized 2026-06-08 — #86 graphify, §5 п.14)
it('allows graphify read-only subcommands (query/explain/path)', () => {
expect(classifyBashCommand('graphify query "x"', {}).result).toBe('allow');
expect(classifyBashCommand('graphify explain "Node"', {}).result).toBe('allow');
expect(classifyBashCommand('graphify path "A" "B"', {}).result).toBe('allow');
});
// graphify mutating/expensive subcommands stay default-deny
it('still blocks graphify mutating subcommands (extract/export/hook)', () => {
expect(classifyBashCommand('graphify extract .', {}).result).toBe('block');
expect(classifyBashCommand('graphify export html', {}).result).toBe('block');
expect(classifyBashCommand('graphify hook install', {}).result).toBe('block');
});
// graphify allowlist is not bypassable via chained commands / subshells — they are
// caught by the gate architecture BEFORE the whitelist regex (per-segment whitelist +
// tokenizer subshell-block + redirect hard-blacklist), so the simple subcommand
// allowlist is safe (security review 2026-06-08 finding = false-positive: $VAR is
// var-expanded away by the tokenizer, not a command-injection vector).
it('blocks graphify chained commands and subshell payloads', () => {
expect(classifyBashCommand('graphify query x; id', {}).result).toBe('block');
expect(classifyBashCommand('graphify query x && rm y', {}).result).toBe('block');
expect(classifyBashCommand('graphify path A `id`', {}).result).toBe('block');
expect(classifyBashCommand('graphify query x | sh', {}).result).toBe('block');
});
// legit read-only graphify with quoted (Cyrillic) question + flags still allowed —
// guards against over-tightening that would reject Unicode queries (tokenizer strips
// quotes → Cyrillic args arrive as barewords).
it('still allows graphify query with quoted question and flags', () => {
expect(classifyBashCommand('graphify query "конфликт дубль" --dfs --budget 1500', {}).result).toBe('allow');
});
});
describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized)', () => {

Some files were not shown because too many files have changed in this diff Show More