Compare commits

...

111 Commits

Author SHA1 Message Date
Дмитрий af15f24de7 feat(map): A1 backend-tooling — NODE_DETAILS + NODE_META для #64-67
Узлы rector/php_insights/backend_patterns/nightowl теперь в панелях описания (nd())
и теплокарте использования (NODE_META, uses:0 новые). Дополняет 5d82fdd (NODES/EDGES/
NODE_SECTION в data.js). Browser-smoke: 141 узел, NODE_META+NODE_DETAILS у всех 4, 0 JS-ошибок.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:36:27 +03:00
Дмитрий b757f22b97 docs(etalon): bump после сквозного чек-листа портала + 6 фиксов (b7466eb)
§1 git HEAD a0e18a1→b7466eb + push a0e18a1..b7466eb (4 commits FF).
§5 schema header drift v8.25→v8.26 устранён (commit 95ee664).
§6 +нить «сквозной чек-лист + 6 фиксов»; «deferred 3 RED теста» → ИСПРАВЛЕНЫ.
cspell-words +2 (захардкоженным, смердженных).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:29:24 +03:00
Дмитрий 31b53557ac style(backend): pint concat_space fix in rector.php
lefthook pint (root:app/ + repo-relative {staged_files}) не обработал rector.php
при 058b239 — known pint-paths quirk. Ручной composer pint исправил concat_space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:27 +03:00
Дмитрий be27713f6e feat(map): +4 A1 backend-tooling nodes + L14 chain (137->141 nodes, 155->165 edges)
NODES +rector/php_insights/backend_patterns/nightowl (все A1); EDGES +10 (реестр-связи
+ L14 backend-quality chain Rector->PHP Insights->Larastan + reuse Boost/billing-audit/Sentry).
Версии-метки v1.35/v2.22/v3.19/v2.19 + router-procedure v1.2. Browser-smoke: 141 узла /
165 рёбер, A1=7 узлов, 0 JS-ошибок (favicon 404 безвреден).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:27 +03:00
Дмитрий 60dd3e70b1 docs(normative): A1 backend-tooling #64-67 — Tooling v2.19 / PSR v3.19 / Pravila v1.35 / CLAUDE v2.22
Атомарный version-bump-набор (cross-ref-checker C2 STRICT). 16-я off-phase подкатегория
backend-tooling (раздел A1): #64 Rector + #65 PHP Insights (Composer dev-deps) + #66
laravel-backend-patterns (self-authored) + #67 NightOwl (DEFERRED). Счётчик 63→67 (87 total).
Tooling §4.39-4.42 (9-attribute blocks) + §0; PSR R10.1 Блок 1 note + R15.6; Pravila §13.2
абзац; CLAUDE §3.3/§6/§9/§0. ADR-013. cross-ref-checker + l1-watcher: 0 drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:26 +03:00
Дмитрий 54967147d7 docs(router): +4 backend nodes routing + L14 chain (routing-off-phase v1.3, router-procedure v1.2)
routing-off-phase v1.3: +4 строки routing #64-#67 (NightOwl DEFERRED) + связка L14
backend-quality chain (Rector->PHP Insights->Larastan->deptrac); scope §4.11-§4.42; #31-#67.
router-procedure v1.2: changelog +backend-tooling узлы в реестр step 3. ADR-013.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:26 +03:00
Дмитрий 1a02b4b5f2 docs(adr): ADR-013 backend-tooling boundaries (BT1-BT9) + NightOwl deferred spike
ADR-013: 4 узла A1 (#64-67) + границы BT1-BT9 + постуры. NightOwl DEFERRED
(native-Windows нет pcntl/posix + OSS без MCP + hosted 152-ФЗ) -> Linux/Б-1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:26 +03:00
Дмитрий 76ea9bbb04 feat(backend): Rector (#64) + PHP Insights (#65) install + configs
Rector: rector/rector ^2.4 + driftingly/rector-laravel ^2.3; app/rector.php
  (deadCode+codeQuality, conservative). composer rector / rector:fix scripts.
  dry-run baseline=16 files -> manual/CI posture, NOT blocking lefthook (ADR-013).
PHP Insights: nunomaduro/phpinsights; app/config/insights.php — SyntaxCheck removed
  (Windows subprocess crash + redundant), style not gated (Pint owns, BT4),
  security-check off. Baseline Code80/Complexity81/Arch75; floors set; composer insights -> 0.
allow-plugins += dealerdirect/phpcodesniffer-composer-installer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:26 +03:00
Дмитрий 62b5306548 feat(backend): laravel-backend-patterns skill (#66) — SKILL + conventions + evals
5 конвенций Лидерры (слоистость / RLS-aware / bcmath-деньги / идемпотентность / partition-aware)
с реальными file:line образцами. Границы: generic→architecture-patterns #38, аудит денег→billing-audit #62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:26 +03:00
Дмитрий 01562afd31 docs(backend): A1 backend-tooling spec + plan + cspell words
Spec: docs/superpowers/specs/2026-05-20-a1-backend-tooling-design.md
Plan: docs/superpowers/plans/2026-05-20-a1-backend-tooling.md
4 узла A1 (#64-67): Rector / PHP Insights / laravel-backend-patterns / NightOwl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:26 +03:00
Дмитрий b7466ebfbd fix(admin): убраны захардкоженные mock-счётчики в админ-меню (Тенанты 142 / Инциденты 3)
Бейджи показывали фиксированные 142/3, расходящиеся с реальными данными
(5 тенантов, 0 открытых инцидентов) — вводили в заблуждение. Удалены; неверный
бейдж хуже отсутствия. Живые счётчики — отдельная фича. TDD: AdminLayout.spec.ts (RED→GREEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:24:17 +03:00
Дмитрий 17e3c04f24 fix(layout): topbar title из route.meta.title для страниц вне sidebar-nav
AppLayout брал заголовок топбара только из sidebar navItems → /reminders и
/import (которых нет в боковом меню) показывали fallback «Страница». Добавлен
fallback на route.meta.title перед «Страница». TDD: AppLayout.spec.ts (RED→GREEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:23:58 +03:00
Дмитрий ba49805689 fix(dashboard): приветствие по реальному имени пользователя + по времени суток
DashboardPageHead показывал захардкоженное «Доброе утро, Иван» любому
пользователю. Теперь имя берётся из auth-store (first_name), а приветствие —
по времени суток (ночь/утро/день/вечер). Fallback «коллега» при отсутствии user.
TDD: DashboardPageHead.spec.ts (RED→GREEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:23:43 +03:00
Дмитрий 95ee6644f7 fix(tests): sync 3 stale эпик-тестов + schema.sql header под Plans 1-3 (v8.26)
Три pre-existing красных теста (ЭТАЛОН §6 «deferred») приведены к реальной
схеме v8.26 после project-migration-redesign Plans 1-3:
- SchemaDeltaTest: 64→65 base tables, 121→123 indexes (project_supplier_links
  pivot + supplier_projects_platform_key_subject_unique).
- SupplierProjectsAccessTest: unique-constraint (platform, unique_key) →
  (platform, unique_key, subject_code) — per-субъект экспорт (Plan 1).
- SupplierLeadFlowTest: routing eligibility теперь через pivot
  project_supplier_links (LeadRouter), не legacy supplier_b1_project_id —
  добавлены linkProjectToSupplier() связи.
- schema.sql header: v8.25→v8.26 + метрики (CHANGELOG уже содержал v8.26).

Production-код не менялся — тесты отставали от уже-смердженных Plans 1-3.
Pest full 1013/1010 passed/3 skipped/0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:23:13 +03:00
Дмитрий a0e18a1dd8 fix(supplier): matching по content в saveProjectMultiFlag — реальный портал возвращает name=B1_X
Реальный портал отдаёт rt-projects-load с name='B1_<id>' / 'B2_<id>' / 'B3_<id>'
и чистым идентификатором в поле 'content'. Старое matching по name === uniqueKey
никогда не совпадало с реальным ответом → idMap пустой → SyncSupplierProjectJob
молча выходил, ничего не записав в БД, а на портале оставались orphan-группы.

Объясняет ранее задокументированное в ЭТАЛОН «проект 5 вылечен вручную —
усыновлены 3 портальные записи». Заказчик обходил тот же баг руками.

Фикс — matching по content с fallback на name, чтобы мок-тесты с упрощённым
форматом (без content) продолжали работать; реалистичная фикстура добавлена
в SupplierPortalClientMultiFlagTest.

Verified:
- Pest supplier suite (SyncSupplierProjectJob/SyncSupplierProjectsJob/multi-flag): 16/16 passed
- E2E live на crm.bp-gr.ru: ProjectService::create + sync → supplier_projects записаны
  с ext_id, pivot заполнен, портал имеет 3 группы B1/B2/B3
- Multi-tenant ночной батч с computeOrder проверен на 79991177889 (T1+T2+T3+T4
  на одном identifier — формула max(max, ceil(Σ/3)) сходится с фактом)
2026-05-20 18:42:20 +03:00
Дмитрий 9e0490c328 docs(etalon): bump после workdays-hardcode + resync-gate fix (80275c6)
§1 git: HEAD c7fd90c80275c6, push 36c71ec..80275c6, lefthook счётчики
обновлены, «незакоммиченного нет».

§6 рабочие нити: +первая запись «Workdays-hardcode + resync-gate в supplier
sync — ИСПРАВЛЕНО И ЗАПУШЕНО (80275c6)» с описанием трёх точек фикса
и cross-ref на память.

Прочее: §6 multi-region запись — телефон 79135191264 заменён на маску
7913XXXXXXX (gitleaks ru-phone-unmasked / 152-ФЗ). §4 «Демо-данные» —
сохранён предыдущий апдейт заказчика про 5 изолированных тенантов
(commit c99362a chore(demo) split-tenants). cspell-words.txt +5
(Незакоммиченного / petr / mariya / хардкодил / Ресинк).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:38:27 +03:00
Дмитрий 80275c6417 fix(supplier): real workdays from delivery_days_mask + resync on limit/days change
Закрывает два бага sync поставщика, обнаруженные при live-проверке создания
проекта «мой номер» (call, 79135191264, лимит 15, дни Пн-Пт):

1. SyncSupplierProjectJob хардкодил workdays=[1..7] в 7 местах и в DTO для
   portal, и в supplier_projects.current_workdays. Заменено на реальную маску
   через приватный workdaysFromMask() (зеркало bitmaskToList ночного батча).

2. forceFill в update-path online mode не включал current_workdays — после
   первого create со старыми [1..7] последующий ресинк не подтягивал
   реальные дни в локальную БД (на portal летели корректные, в нашей таблице
   оставались stale).

3. ProjectService::update() ресинкал только при смене sms_*/signal_identifier/
   regions. Добавлены daily_limit_target и delivery_days_mask — поставщик
   видит новый лимит и дни сразу, не дожидаясь ночного батча 18:00 МСК.

Тесты:
- SyncSupplierProjectJobTest: +2 specs (real-workdays create-path, update-path
  current_workdays refresh).
- ProjectsUpdateTest: «without resync» переписан в name-only, +2 specs
  (daily_limit_target и delivery_days_mask change → resync).
- Pest 146/146 (Supplier + Plan5/Projects scope), Pint passed, Larastan 0.

Live-ресинк проекта id=5 «мой номер» в dev DB выполнен — current_workdays
теперь [1,2,3,4,5], HTTP ушёл к crm.bp-gr.ru с теми же днями.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:33:46 +03:00
Дмитрий 36c71ecb1e fix(supplier): одна группа на идентификатор — сливаем все регионы проекта
Портал crm.bp-gr.ru возвращает status=Doubles при попытке создать
вторую группу с тем же unique_key. Старый код делал одну B1/B2/B3-группу
на каждый регион проекта — вторая группа молча пропадала.

Теперь оба джоба (SyncSupplierProjectJob + SyncSupplierProjectsJob)
формируют ровно одну группу на идентификатор со всеми регионами:
- regions=[82,83] → tag='РФ', regions=[82,83] в одной группе
- regions=[] → tag='РФ', regions=[] (вся РФ)
- regions=[82] → tag='Москва', regions=[82]
subject_code=null во всех supplier_projects и project_supplier_links.

ProjectService::update() теперь триггерит SyncSupplierProjectJob
при изменении поля regions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:46:27 +03:00
Дмитрий c99362a3e5 chore(demo): скрипт разбивки 5 демо-учёток на 5 изолированных тенантов
Каждый логин (admin/manager1-4) → своя компания/тенант.
Идемпотентный: firstOrCreate + reassign tenant_id.
Запуск: php artisan tinker storage/_demo_split_tenants.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:08:08 +03:00
Дмитрий 9331465c26 fix(layout): меню топбара не уходит за экран при reduced-motion
Активатор v-menu внутри position:fixed v-app-bar уезжает off-screen под
prefers-reduced-motion:reduce (умолчание Windows Server). Подключён
repositionMenuAfterOpen к обоим меню топбара через @update:model-value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:07:47 +03:00
Дмитрий 9d9bcf7847 docs(etalon): migration channels verified live; inbound configured; DB v8.26 demo restored
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:46:17 +03:00
Дмитрий c7fd90c08d fix(deals): читать проекты из конверта { data } + чинить фикстуры LeadStatus
DealsView крашился (Cannot read properties of undefined reading 'map'): listProjects() читал data.projects, но ProjectController::index() отдаёт { data: [...] } после миграции на JsonResource — availableProjects=undefined ломал .map, фильтр «Проект» был пуст. Фикс: читать data.data ?? []. + deals-api.spec.ts тест на новый конверт + защитный []. + DealDetailHero.spec.ts: фикстуры LeadStatus (isSystem/sortOrder вместо order) — устранён pre-existing type-check error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:20:53 +03:00
Дмитрий e35fc6c938 feat(projects): require region + explicit «Вся РФ» with warning gate
План 4 Task 4 эпика project-migration-redesign.

- NewProjectDialog: отдельный чекбокс «Вся РФ» (89 субъектов в autocomplete
  без sentinel сохранены) + inline v-alert предупреждение + подтверждение.
- Взаимоисключение: выбор субъектов снимает «Вся РФ» и наоборот.
- Гейт submit: блок если ни субъектов, ни подтверждённой «Вся РФ»
  (errors.regions = «Выберите регион...»); «Вся РФ» -> regions=[] на API.
- Лейбл autocomplete «Регионы» (убрано «(пусто = вся РФ)»).
- watch immediate:true — инициализация vsyaRf/edit-prefill при mount
  (чинит EditProjectDialog submit при модальном открытии).
- Vitest 3/3 новых + 22 passed соседних (NewProject/Edit/ProjectsView) без регрессий.
2026-05-20 14:34:27 +03:00
Дмитрий f1a3e9f02f feat(admin): supplier projects cleanup screen (list + bulk delete)
План 4 Task 3 эпика project-migration-redesign.

- AdminSupplierProjectsView.vue — v-data-table (источник/платформа/регион/
  лимит/кто заказывал/последняя поставка) + bulk-delete с v-dialog
  подтверждением + snackbar (deleted/failures).
- Роут /admin/supplier-projects (layout admin, requiresAuth, devIndex 31).
- AdminLayout nav-пункт «Проекты у поставщика».
- Vitest 3/3 (mount GET, bulk-delete confirm POST {ids}, disabled when empty).

NB: type-check имеет 3 pre-existing ошибки в DealDetailHero.spec.ts
(коммит 1412d3f, не Plan 4); файлы T3 type-check-чисты.
2026-05-20 14:34:25 +03:00
Дмитрий d0eecbbf79 feat(admin): supplier projects list (orderers, last delivery) + bulk delete
План 4 Task 2 эпика project-migration-redesign.

- AdminSupplierIntegrationController +projectsIndex (список supplier_projects
  + кто заказывал через pivot project_supplier_links -> projects -> tenants
  organization_name + дата последней поставки = max supplier_leads.received_at
  + subject_name из RussianRegions::CODE_TO_NAME, «РФ» при NULL subject_code).
- +projectsDestroy (bulk-delete: deleteProject на портале, затем локально;
  pivot снимается CASCADE; сбой строки не прерывает batch -> failures[]).
- Routes: GET /projects, POST /projects/delete в admin-группе.
- Pest 5/5 (26 assertions). phpstan-baseline +9 ignore (Pest TestCall).
2026-05-20 14:34:23 +03:00
Дмитрий 01d292f5a9 feat(admin): supplier export-mode toggle (online|batch) endpoint + UI
План 4 Task 1 эпика project-migration-redesign.

- AdminSupplierIntegrationController +getExportMode/setExportMode
  (validation in:online,batch; system_settings upsert).
- Routes: GET/POST /api/admin/supplier-integration/export-mode
  в admin-группе рядом с manual-queue.
- AdminSupplierIntegrationView.vue +секция «Режим экспорта проектов»
  с v-btn-toggle (online|batch), подпись о ночном синке 18:00.
- Pest 3/3 + Vitest 2/2 (+ соседние 5 не сломаны).
- phpstan-baseline.neon +6 ignore (Pest TestCall::actingAs/getJson/postJson
  — типовой паттерн, как в SupplierManualQueueTest).
2026-05-20 14:34:22 +03:00
Дмитрий b0ce510155 docs(observer): retro note + epic plan v1.1 (Task 21)
Closes the «Observer instrument expansion v2» epic. The retro note is
the source of all #1-#19 references in commit messages; the plan is
the procedural source (with REVISION v1.1 after parallel-session rebase).

Both kept in repo for traceability of the 20-commit epic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:45 +03:00
Дмитрий 76d13d699a docs(spec): observer factor-analysis v1.1 → v1.2 instrument expansion
Sync header + §12 changelog summarising the 18-task epic «Observer
instrument expansion v2» implementation. Each subsection (§12.1-§12.9)
references the brain-retro 2026-05-20 #N item and the worktree commit
chain.

Closes Task 20 of docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:44 +03:00
Дмитрий be9571353a feat(status-md): surface legacy v1 episodes count
Closes brain-retro 2026-05-20 #18 — episodes without schema_version=2
(legacy v1 era pre-2026-05-19T08:06) are now visible in STATUS.md
metrics. They're already filtered out of factor analysis by analyzer's
v1SkippedCount, but their existence was invisible to humans reading
STATUS — masking the bootstrap-epoch gap.

2 new vitest tests, 326/326 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:44 +03:00
Дмитрий 147200ff8e tools(observer): add Glob latency investigator (ad-hoc script)
Closes brain-retro 2026-05-20 #17 — one-off Node script for investigating
the Glob p50=12.7s anomaly from initial retro. Parses transcript JSONL,
prints top-N slowest Glob round-trips with pattern + path.

Smoke-tested on session 553717ec (5h+ session): finds 32 Glob calls,
median 12690ms (matches retro finding), top-5 all 'docs/adr/**' at
20265ms — Glob recursive on ADR directory is the apparent culprit.

NOT production code path — never imported by parser/hook/analyzer.
Run on demand: node tools/glob-latency-investigator.mjs <transcript.jsonl>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:43 +03:00
Дмитрий 492a4fc969 feat(observer): inferOutcome neutral next-prompt → soft_success
Closes brain-retro 2026-05-20 #16 — when the next prompt is 'neutral'
(no correction/approval/new_task markers), interpret as silent success
('no objection') and surface as soft_success. Slightly weaker than
explicit approval — labelled separately so brain-retro can show
breakdown.

4 new vitest tests, 324/324 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:43 +03:00
Дмитрий 5742c92449 docs(skill): /brain-retro step 8a refreshes STATUS.md after save
Closes brain-retro 2026-05-20 #19 — после save retro-note runs
status-md-generator. STATUS.md becomes immediately current
(Last /brain-retro: 0 day(s) ago, fresh episode count). Without this,
STATUS only updated at next post-commit hook fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:42 +03:00
Дмитрий e846de6012 docs(skill): /brain-retro step 4 uses observer-of-observer record command
Closes brain-retro 2026-05-20 #15 — replaced abstract 'bump' instruction
with explicit 'node tools/observer-of-observer.mjs record'. Atomic
read-modify-write via fs, reuses same module that C3 isStale uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:42 +03:00
Дмитрий a007295abe refactor(observer): rename factor axis session_turn → session_segment_turn
Closes brain-retro 2026-05-20 #14 — `environment.session_turn` уже значит
'turns since last compaction' (parser counts from lastCompactIdx + 1).
Ось матрицы под именем 'session_turn' путала с глобальным turn-номером.
Семантика данных не меняется, только имя axis в FACTOR_FNS.

Existing test renamed; new explicit test verifies new name present and
legacy name absent.

1 new vitest test + 1 renamed, 320/320 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:41 +03:00
Дмитрий 5d3e29669b feat(observer): parallel_session +OR pre-flight git fetch heuristic (Task 13 PIVOT)
Closes brain-retro 2026-05-20 #13 PIVOT — additive to F1 (parallel
session sessions session). F1 narrowed parallel_session to tool_result-only
to fix live FP. This Task adds OR-clause: Bash command containing
'git fetch && git log HEAD..origin/...' (Pravila §15.2 pre-flight)
is a strong signal that the operator expects parallel sessions.

Does NOT overwrite F1 — both signals coexist via OR.

4 new vitest tests, 319/319 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:41 +03:00
Дмитрий ef4cc825bf feat(observer): emit subagent_invoked events from Agent tool_use
Closes brain-retro 2026-05-20 #12 — each Agent tool_use produces a
subagent_invoked event with subagent_type / model (if explicit) /
first 80 chars of description. Visibility from parent Claude's
perspective; full subagent trace lives in subagents/ directory and is
out of scope for this parser.

6 new vitest tests, 315/315 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:40 +03:00
Дмитрий f54c82d682 feat(observer): opt-in reasoning-tag merges with heuristic primary_rationale
Closes brain-retro 2026-05-20 #11 — parseReasoningTag extracts opt-in
<!-- reasoning: triggers="..." candidates="..." boundaries="..." -->
HTML-comment from assistant text. Semicolon-separated values merged into
heuristic-derived primary_rationale arrays via Set-dedupe.

Conservative: tag is opt-in; heuristic still runs even when tag present
(heuristic provides baseline, tag enriches).

5 new vitest tests, 309/309 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:39 +03:00
Дмитрий 884169e847 feat(status-md): show last /brain-retro days-ago
Closes brain-retro 2026-05-20 #10 — STATUS.md теперь сообщает, когда
последний раз был прочитан observer (через .read-counter.json
last_read_at). Помогает не забыть про ретро между sprint-кадансами.

3 new vitest tests, 304/304 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:39 +03:00
Дмитрий f8b32a7d3a feat(observer): extend classifyPromptSignal vocabulary
Closes brain-retro 2026-05-20 #9 — добавлены маркеры:
- correction: 'не совсем', 'другое|другая', 'не сходится', 'wrong direction'
- approval: 'класс', 'хорошо', 'принято', 'well done', 'nice'
- new_task (prefix): 'теперь', 'далее', 'следующее', 'next', 'now'

NB на JS \b с Cyrillic: \b matches word↔non-word boundary, но Cyrillic
chars не word-chars в JS RegExp default → \b после русского слова
никогда не fires. Решение: substring-match для русских correction-маркеров;
lookahead с явными разделителями для start-of-prompt new_task маркеров.

11 new vitest tests, 301/301 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:38 +03:00
Дмитрий ffaeb8f37b feat(observer): strip <system-reminder> blocks from promptText
Closes brain-retro 2026-05-20 #8 — UserPromptSubmit hook injects
<system-reminder>...</system-reminder> blocks into user.content that
polluted classifyTask / classifyPromptSignal / routing detection.
Now stripped via regex before any analysis.

Completed by controller (Opus) after subagent hit context limit on
1250-line test file. Helper stripSystemReminders + promptText update
were committed by subagent; test cases appended via Bash heredoc.

4 new vitest tests, 290/290 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:47:38 +03:00
Дмитрий c0e3e901d0 feat(observer): differentiate error events by tool + summary
Closes brain-retro 2026-05-20 #7 — each tool_result.is_error now emits
{ kind:'error', tool:<name>, summary:<first 80 chars> }. Allows
aggregation by tool (Bash/Edit/Read) + cause prefix (ENOENT/timeout/
'String to replace not found').

Required updating existing 'emits error events for tool_result with
is_error' test assertion (old shape had bare 'message' field).

4 new vitest tests + 1 existing relaxed, 286/286 GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:47:37 +03:00
Дмитрий 0663479bb8 feat(observer): heuristic reasoning capture in primary_rationale
Closes brain-retro 2026-05-20 #6 — extractTriggers/Candidates/Boundaries
scan assistant.text for Pravila §N / ADR-N / PSR_v1 RX / routing-off-phase
LN / hard-floor + numbered/bulleted lists (≥2). Populates previously-
always-empty primary_rationale arrays.

Conservative-broad: false positives accepted (mention ≠ application);
/brain-retro determines applied validity. Phase 2 agent-judge out of scope.

19 new tests, 282/282 GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:47:37 +03:00
Дмитрий 52728dfc12 feat(observer): capture ask_user_question events with answer_kind classification (Task 4)
Add extractAskUserQuestionEvents() — for each AskUserQuestion toolUseResult emits
one event per question with answer_kind: option|custom|no_answer and question_count.
Integrated into parseTranscript events pipeline. 7 new tests (263 total, 0 failed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:47:36 +03:00
Дмитрий dbe2252421 feat(observer): real PII counter — STATUS.md stops lying
Closes brain-retro 2026-05-20 #3 SIMPLIFIED — sanitizeWithCount in
pii-filter (counts matches per pattern) + persistent monthly counter
docs/observer/.pii-counters.json (bumped by Stop-hook on each episode
write) + status-md-generator reads real count (no more piiMatches: 0
hardcode).

PII patterns themselves NOT changed (F7 of parallel session already
extended to 13 patterns).

Counter is informational — write failure never blocks Stop-event.

5+1+1=7 new vitest tests, 256/256 GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:47:36 +03:00
Дмитрий 8e5eaecf6a feat(observer): Task 2 — extractTokenUsage + task_cost in parseTranscript
- export extractTokenUsage(turn): sums input/output/cache/iterations/
  web_search/web_fetch across all assistant messages in a turn
- parseTranscript now includes task_cost field (zero-filled when no usage)
- 7 new tests (5 unit + 2 integration); total 248/248 GREEN
- V2_FIELDS in observer-stop-hook.mjs NOT changed (backward compat)
2026-05-20 13:47:35 +03:00
Дмитрий 47c03a9e18 feat(observer): extend classifyTask with 7 new classes
Closes brain-retro 2026-05-20 #1 — analysis/memory-sync/regulatory-bump/
release/cleanup/monitoring/planning. Addresses '59% other' observation
from initial retro factor matrix.

Ordering: release before feature (merge feature-branch), planning before
refactor (план рефакторинга), memory-sync/regulatory-bump at top as most
specific. monitoring regex проверь состоян covers inflected forms.

9 new vitest tests, 241/241 GREEN in npm run test:tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:47:34 +03:00
Дмитрий 752ff8b9a9 feat(infra): add test:tools npm script (B3-1)
Canonical entry point for tools/observer-*.test.mjs Vitest runner.
Closes B3-1 from brain-retro 2026-05-20 (АДДЕНДУМ B3).

Run via: npm run test:tools (in repo root)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:47:34 +03:00
Дмитрий c7197a263c docs(эталон): обновление после push эпика project-migration-redesign (HEAD 9729909, Plans 1+2+3 closed) 2026-05-20 13:43:40 +03:00
Дмитрий 9729909c31 docs(supplier): fix naked app/ refs to ../../../app/ in failover plan (lychee gate) 2026-05-20 13:36:55 +03:00
Дмитрий 2bab9a61b9 fix(supplier): T6 online-mode 3 review-Important — tier-1-only docblock, partial-set re-attempt, per-platform DTO update 2026-05-20 13:29:08 +03:00
Дмитрий 082968ea1c feat(supplier): online-mode full-param per-subject sync + grouping helpers 2026-05-20 12:34:27 +03:00
Дмитрий 2d7201f063 feat(supplier): SyncSupplierProjectsJob per-subject grouping + pivot + order 2026-05-20 12:24:35 +03:00
Дмитрий 96f4a6601d feat(supplier): saveProjectMultiFlag R5 + tag/platforms DTO (R6/R7) 2026-05-20 12:16:14 +03:00
Дмитрий 48b0e35cd1 docs(supplier): R-SAVE multi-flag mapping finding (Plan 3 T1 read-only verified) 2026-05-20 12:10:21 +03:00
Дмитрий c89895e039 feat(supplier): order formula max(max, ceil(sum/3)), drop platform split 2026-05-20 12:07:17 +03:00
Дмитрий 3cf8fbdfb9 feat(supplier): SupplierExportMode toggle resolver (online|batch) 2026-05-20 11:57:59 +03:00
Дмитрий d6364dcde1 refactor(tests): consolidate linkProjectToSupplier helper to tests/Pest.php (Plan 2 review I-1/I-2) 2026-05-20 11:52:47 +03:00
Дмитрий d631646167 feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag 2026-05-20 11:46:24 +03:00
Дмитрий 2706166f55 test(supplier): pivot-link AutoPause+Billing tests + redeclare guard (Plan 2 cascade) 2026-05-20 11:46:13 +03:00
Дмитрий b584ce43dd feat(supplier): LeadDistributor cap=3 seedable random selection 2026-05-20 11:30:00 +03:00
Дмитрий 6b7f0035ef feat(supplier): LeadRouter eligibility via pivot, drop phone region filter 2026-05-20 11:26:40 +03:00
Дмитрий 3e16c1e656 feat(supplier): RegionTagResolver + RussianRegions (subject name->code) 2026-05-20 11:22:06 +03:00
Дмитрий e6d6babb38 feat(supplier): deals.subject_code range CHECK 1..89 (defensive parity) 2026-05-20 11:15:14 +03:00
Дмитрий 2476dd3c1b fix(observer): expand PII patterns — JWT/AWS/Yandex/IPv4/OS-username
PII filter previously covered only RU phone, email, Sentry, OpenAI token,
and generic Bearer. Several common surface leaks were uncovered:

- JWT tokens (eyJ<base64>.<base64>.<base64>) — auth/session tokens.
- AWS access key IDs (AKIA<16 alphanum>) — IAM static creds.
- Yandex Cloud IAM static keys (AQVN<base64>), session tokens (t1.<base64>),
  OAuth tokens (y0_<base64>) — primary cloud-provider for this project.
- IPv4 addresses (dotted-quad) — over-redacts 4-segment build numbers as
  an accepted tradeoff (under-redaction is the worse failure).
- Windows user-paths (C:\Users\<name>) → C:\Users\***. Otherwise the OS
  username `Administrator` leaks via task_size.files in every episode.
- POSIX /home/<name>/ → /home/***/. Same rationale for Linux dev hosts.

Pattern order: highly-specific token patterns (JWT/AWS/YC) run BEFORE
OPENAI_TOKEN/GENERIC_BEARER fallbacks; otherwise partial overlaps would
strip the wrong segments.

Tests: 9 new (each new pattern + idempotency over the expanded redaction
markers). 27/27 PII tests green.

.gitleaks.toml: added the test fixture to the path allowlist — the file
contains synthetic JWT/AWS/Yandex tokens (the filter is supposed to redact
them), not real secrets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:10:53 +03:00
Дмитрий 3ec638cbd2 fix(observer): C5 coverage driven by hook registration, drop commit ratio (COV-1)
Bug: checkCoverage flagged anomaly when "recent commits > 0 AND episodes == 0".
Two design flaws, proven in this project:
- Wrong unit: commits = work-unit (one turn → many commits via subagent
  workflow); episodes = turn-unit. A 1023-vs-19 ratio is not anomalous, it's
  expected.
- Wrong window: the 14-day commit window predated the Stop-hook's existence
  (registered 2026-05-19). For 13 of 14 days the hook didn't exist — 889
  commits were structurally impossible to mirror as episodes.

Result: the C5 indicator was either always-red (flagging the hook's birth
as anomaly) or always-green (any episode count vs huge commit count = ok).
Either way uninformative.

Fix:
- checkCoverage(episodeCount, hookRegistered) — drops the commit param.
  Warn iff hook is registered AND 0 episodes this month → the hook is
  silently failing. If the hook isn't registered, 0 episodes is correct.
- runCoverageChecker derives hookRegistered from settings.json
  (isObserverStopRegistered helper) and passes it to checkCoverage.
  No more git execFileSync — pure fs.

Tests rewritten under the new contract: 7/7 (was 6, +1 drift-hazard guard
ensuring detail strings never mention "commit"). 15/15 coverage tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:07:58 +03:00
Дмитрий c5ec9a0875 feat(supplier): backfill project_supplier_links from legacy FK slots 2026-05-20 11:06:13 +03:00
Дмитрий 3b7e549e02 fix(observer): validate prompt_signal + events in appendEpisode (C-7)
V2_FIELDS list omitted prompt_signal and events — both are always produced
by parser and buildEpisodeFromContext, so the happy path is unaffected, but
a future ctx-fallback path that dropped them would silently write a
malformed episode. Add both to V2_FIELDS; appendEpisode now throws on either
being missing.

Tests: 2 new — appendEpisode throws when prompt_signal missing /
when events missing. 38/38 stop-hook tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:05:56 +03:00
Дмитрий 7fe9f89574 fix(observer): exclude hot/normative files from causal chains (A-3)
Bug: findCausalChains flagged a chain whenever two episodes shared any
file. CLAUDE.md / MEMORY.md / STATUS.md / episodes-YYYY-MM.jsonl /
memory/*.md are touched by almost every turn (memory store, status
regeneration, normative-doc updates) — sharing them is not evidence of
causality, just baseline noise. Result: spurious chains on hot files
crowded out the genuine signal.

Fix: HOT_FILE_PATTERNS regex list + `isHotFile(path)` predicate. In
findCausalChains, filter hot files out of BOTH the errored-episode file
set AND the candidate-shared list. If only hot files were shared → no
chain. If a non-hot file is also shared → the chain stands and the
sharedFiles list contains only the non-hot ones.

Tests: 4 new cases — CLAUDE.md / memory/*.md / episodes/STATUS/MEMORY
sharing yields no chain; a turn sharing both CLAUDE.md AND /src/app.ts
yields a chain with sharedFiles=['/src/app.ts'] only. 33/33 analyzer
tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:04:59 +03:00
Дмитрий c5def50e31 feat(supplier): Project<->SupplierProject belongsToMany via pivot 2026-05-20 11:04:24 +03:00
Дмитрий c386361881 fix(observer): infer blocked from unrecovered_error tail, not raw error/retry count (A-1)
Bug: inferOutcome flagged `blocked` whenever errorCount > retryCount across
the turn's events. But the parser emits an `error` event for ANY tool_result
with is_error=true — including expected failures: TDD failing-test-first,
grep returning nothing, git commands with intentional non-zero exit. On
TDD-heavy turns (project's standard discipline) this systematically marked
turns as blocked even when they ended on a successful tool_use.

Fix:
- Parser (extractProcessEvents): walk turn from end, find the LAST
  tool_result; if its is_error=true, emit a single `unrecovered_error`
  event. Distinguishes "turn ended on failure" from "errors recovered
  later". The original per-is_error `error` events remain (useful as raw
  factor signals).
- Analyzer (inferOutcome): replace `errorCount > retryCount → blocked`
  with `events.some(kind === 'unrecovered_error') → blocked`. Same
  ordering preserved (interrupt > blocked > rework/success/unknown).

Tests:
- Parser: emits unrecovered_error when last tool_result is_error;
  does NOT emit when turn ended on a successful tool_result;
  does NOT emit for turns with no tool_results.
- Analyzer: blocked iff unrecovered_error event present (not raw count);
  events=[error, error, retry] → success (no unrecovered_error).

142/142 vitest green (was 128).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:03:15 +03:00
Дмитрий 94f831f7d1 fix(observer): uuid-dedup in parseLines (C-1 root fix for quirk #101)
Bug: Claude Code's transcript JSONL file accumulates duplicated context-
rebuild snapshots — the same entry re-printed with the SAME `uuid`. Without
dedup, session_turn / task_size / events double-count, and session_turn
becomes non-monotonic across episodes parsed at different file-growth
states. Live evidence: episodes-2026-05.jsonl lines 14/15/16 of the same
session showed session_turn 139 → 140 → 91 (backwards in time). Probe
on transcript 553717ec: 22400 entries, only 6074 unique uuid (68% dup
rate); real user prompts 264 total vs 92 unique-uuid.

Fix: parseLines now tracks a `seenUuid` Set and skips entries whose uuid
has already been encountered (keep-first). Entries without `uuid`
(synthetic test fixtures) pass through unchanged. All downstream functions
(findTurnStart, extractEnvironment, extractTaskSize, etc.) operate on the
deduped entries array, so the fix is single-point and total.

Tests: new `parseTranscript — uuid-dedup` describe block covers
(1) duplicated-uuid prompts collapse → session_turn counts once,
(2) distinct-uuid entries preserved (no over-dedup),
(3) no-uuid entries pass through (synthetic-fixture safety),
(4) duplicated-uuid assistant turns → tool_calls / files_touched counted once.
110/110 parser tests green (was 106).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:00:50 +03:00
Дмитрий 1ba8b6e590 feat(supplier): seed supplier_export_mode toggle (v8.26) 2026-05-20 10:59:27 +03:00
Дмитрий 030bdc65ab fix(observer): narrow parallel_session detector to tool_result evidence (C-2)
extractEnvironment was scanning JSON.stringify(turn) for collision markers
(чужой staged / foreign git index / index.lock / another git process). Prose
mentions in user/assistant text flipped parallel_session=true. Live FP proven
on episodes-2026-05.jsonl line 20: my own analysis turn was non-parallel but
recorded parallel_session: true because the finding text mentioned the markers.

Fix: collectToolResultText(turn) — gather text only from tool_result blocks
(both string content and structured `[{type:text,text}]` arrays). Scan THAT
for collision markers; prose is no longer a signal.

Tests: rewrote `parallel_session narrowed` block — false on user/assistant
prose / no-tool-result turns; true on tool_result strings + structured form.
106/106 parser tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:58:37 +03:00
Дмитрий 148262a78e feat(supplier): deals.subject_code from supplier tag (v8.26) 2026-05-20 10:57:04 +03:00
Дмитрий 787c38ad82 feat(supplier): project_supplier_links M:N pivot (v8.26) 2026-05-20 10:54:50 +03:00
Дмитрий 79d3f2ef3d test(supplier): isolate subject_code test (DatabaseTransactions+SharesSupplierPdo) 2026-05-20 10:48:32 +03:00
Дмитрий 82c0aeef41 feat(supplier): supplier_projects.subject_code + per-subject unique index (v8.26) 2026-05-20 10:45:02 +03:00
Дмитрий 5f17ca51ac chore(tools): worktree pre-commit gate runner (quirks #86/#97)
In a git worktree the shared .git/hooks/pre-commit cannot find lefthook on
PATH and silently skips every gate (pint/larastan/pest/gitleaks). This
script hardcodes the lefthook.exe + lefthook.yml paths from the main
checkout and runs `pre-commit` explicitly. Run before `git commit` inside
any worktree. Exit 0 = all gates passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:32:31 +03:00
Дмитрий fdd8247527 fix(tests): ProjectFactory unique name — Str::random suffix (quirk #77)
fake()->unique() builds a fresh UniqueGenerator per definition() call, so
uniqueness is not guaranteed within a batch — names collided on the
(tenant_id, name) UNIQUE under pest --parallel. Append Str::random(8)
(62^8 ≈ 2e14 space) to eliminate the collision.

Verified: ProjectBulkActions 15/15 ×2 parallel runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:32 +03:00
Дмитрий d1ddd28250 docs(plan): Plan 4 (админка + ЛК) — переделка миграции проектов
5 TDD-задач: тумблер режима экспорта (endpoint + UI), экран «Проекты у поставщика»
(кто заказывал/дата последней поставки + bulk-delete бэк/фронт), ЛК require-region
UI-гейт + «Вся РФ» предупреждение/подтверждение, полная регрессия. Финальный из
4 планов эпика. +cspell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:26 +03:00
Дмитрий 34458df474 docs(plan): Plan 3 (экспорт + заказ) — переделка миграции проектов
8 TDD-задач: R-SAVE live smoke (гейт), SupplierExportMode тумблер, формула заказа
max(наиб,ceil(Σ/3)) + убран split, saveProjectMultiFlag R5/R6/R7 (захват 3 id),
SyncSupplierProjectsJob группировка источник×субъект + pivot, онлайн mode-aware
sync + grouping-хелперы, крон 18:00, регрессия. Третий из 4 планов. +cspell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:24 +03:00
Дмитрий 467f1cdbf2 docs(plan): Plan 2 (входящее распределение) — переделка миграции проектов
5 TDD-задач: RegionTagResolver (тег субъекта -> код, зеркало regions.ts),
LeadRouter на pivot без phone-фильтра, LeadDistributor cap=3 (seedable RNG),
RouteSupplierLeadJob (cap + deal.subject_code из тега), регрессия.
Второй из 4 планов эпика. +cspell. Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:22 +03:00
Дмитрий cd2353b57d docs(plan): Plan 1 (фундамент данных) — переделка миграции проектов
7 TDD-задач: supplier_projects.subject_code + per-subject unique (NULLS NOT
DISTINCT), pivot project_supplier_links (замена 3 FK-слотов), deals.subject_code,
seed supplier_export_mode, belongsToMany связи, backfill pivot, регрессия.
Первый из 4 планов эпика (см. spec §3). +cspell сид/бэкофилл. Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:20 +03:00
Дмитрий 17e34a6d5e docs(spec): design — переделка миграции проектов + распределения лидов
Закрыты 5 под-вопросов brainstorming + P1 (Вся РФ = 1 пул + предупреждение
с подтверждением) + P2 (один связный spec). Ядро: 3-FK слоты -> M:N pivot,
per-субъект supplier_projects (subject_code), формула заказа max(наиб, ceil(Σ/3)),
cap=3 рандом из недобравших, ручной экран очистки в админке, режимы экспорта
online/batch (глобальный тумблер). R2 уже 18:00. R-SAVE = вариант а (дочитать
listProjects). Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:08:31 +03:00
Дмитрий 063436670a feat(map): finance-tooling — populate C6+C7 (+3 nodes, +7 edges)
+finance_plugin (C7+C6) / billing_audit (C6) / ru_tax (C7) + reuse secondary-
классификация (Boost/Pest/Larastan/Sentry/Redis/PM/data-scientist/operations/
process-*/context7) + NODE_DETAILS + NODE_META + версии-метки (pravila v1.34 /
claude_md v2.21 / psr_v1 v3.18 / tooling v2.18). JS-smoke: 137 nodes / 155 edges,
0 drift. ADR-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:54:25 +03:00
Дмитрий 2f9f0a0900 docs(router): finance-tooling routing rows + L13 chain (routing-off-phase v1.2)
+3 строки routing (#61 finance plugin / #62 billing-audit / #63 ru-tax-accounting),
связка L13 (финансовая цепочка C6->C7), scope §4.11->§4.38. router-procedure v1.1
changelog. ADR-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:51:28 +03:00
Дмитрий c44394ea0c docs(normative): finance-tooling #61-#63 cross-ref version bump
Tooling Прил.Н v2.18 (§4.36/37/38 + §0 60->63 + 15-я подкатегория) +
PSR_v1 v3.18 (R10.1 Блок 1 +finance + note) + Pravila v1.34 (§13.2 +абзац) +
CLAUDE.md v2.21 (§3.3 +#61-63 + §0 cross-refs + §6 + §9).
Атомарный version-bump-набор (cross-ref-checker C2 STRICT: 0 drift). ADR-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:49:52 +03:00
Дмитрий 3177072e1d docs(adr): ADR-012 finance-tooling boundary C6/C7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:40:33 +03:00
Дмитрий 71022ad3f1 feat(finance): ru-tax-accounting skill — РСБУ/НК РФ context C7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:37:52 +03:00
Дмитрий 6d9c1d2464 feat(finance): billing-audit skill — money invariants C6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:31:30 +03:00
Дмитрий de11da2b06 docs(finance): C6+C7 finance-tooling implementation plan
11 задач в 3 фазах: Ф1 billing-audit скил (C6), Ф2 finance plugin enable +
ru-tax-accounting скил (C7), Ф3 нормативка (Tooling/PSR/Pravila/CLAUDE) +
роутер (routing-off-phase L13 + router-procedure) + наблюдатель (9-атрибутные
блоки + C1/C2) + карта (+3 узла) + ADR-012 + push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:19:50 +03:00
Дмитрий d984165af1 docs(finance): C6+C7 finance-tooling epic design spec
Объединённый эпик «Финансы»: наполнение разделов карты C6 (биллинг/тарификация)
+ C7 (бухгалтерия/налоги). 3 новых узла (#61 finance plugin, #62 billing-audit,
#63 ru-tax-accounting) + reuse-классификация + расширенная нормативка
(роутер routing-off-phase.md + наблюдатель 9-атрибутные блоки) + ADR-012.
+9 терминов в cspell-words.txt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:11:07 +03:00
Дмитрий 7df4786499 docs(discovery): brief переделки миграции проектов + распределения лидов
Зафиксированы решения discovery-интервью 2026-05-20: два режима экспорта
проектов (онлайн + пакетный 18:00 МСК), один save с тремя флагами B1+B2+B3,
tag=регион, и новый алгоритм распределения лидов (cap=3 рандом из недобравших,
заказ = max(наиб_лимит, ceil(Σ/3)); группировка отменена). Реализация не начата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:58:57 +03:00
Дмитрий 162fe010fe feat(map): iter9 — brain governance subsystem (+9 nodes, +12 edges, +1 GREEN)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:12:24 +03:00
Дмитрий 426983ffaa docs(map): iter9 implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:59:29 +03:00
Дмитрий 87c5eb6323 docs(map): spec self-review fix — edges 13->12
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:50:18 +03:00
Дмитрий cb864b18a5 docs(map): iter9 brain-governance design spec
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:49:32 +03:00
Дмитрий 4b4c8d94b9 docs(etalon): refresh snapshot after supplier-migration-followup epic (HEAD 8f5a399→dd0a9ff, demo re-seed, failover live-smoke) 2026-05-20 04:10:09 +03:00
Дмитрий dd0a9ffea6 docs(observer): sync spec §6 with as-built factor-analyzer
§6 drifted from the implemented brain-retro analyzer after Phase 1.2/1.3:
- factor matrix now lists 9 axes (session_turn + parallel_session were
  captured in the episode schema §3 but missing from the §6 matrix);
- outcome inference documents 'blocked' (error events > retry events) and
  notes 'failure' as deferred to the phase-2 agent-judge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:17:17 +03:00
Дмитрий 353b1599b6 fix(observer): brain-retro analyzer — blocked outcome + v1 filter + factors
P0.1b: inferOutcome emits 'blocked' when a turn had more error than retry
events (an unrecovered tool failure) — previously the enum value was dead.

P0.1c: 'failure' documented as deferred to the phase-2 agent-judge. It is a
judgment (work wrong AND never corrected), not deterministically recoverable
from a transcript; a wrong-then-corrected turn surfaces as 'rework'.

P1.1: analyze() drops v1 episodes (no schema_version 2) — they lack
environment/prompt_signal/decision_provenance and polluted the factor
matrix. Reports v1SkippedCount.

P2.1: session_turn (bucketed early/mid/late) and parallel_session added to
FACTOR_FNS — closes the schema↔matrix mismatch (both were captured in the
episode but absent from the factor axes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:44 +03:00
Дмитрий 97388cf840 fix(observer): transcript-parser accuracy — session_turn + correction signal
P0.2: count session_turn from the last compaction. The transcript file
accumulates duplicated context-rebuild snapshots (quirk #101), so counting
real prompts from i=0 inflated it and made it non-monotonic. Now counts
"real prompts since the last compaction" — monotonic by construction.

P0.1a: widen the correction prompt_signal regex (не работает / сломал /
опять / откати / revert / still not / wrong / ...). The old regex was too
narrow, so rework outcomes were invisible to the factor analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:29 +03:00
Дмитрий 8f5a399a25 docs(discovery): 3-tier failover live-smoke 2026-05-19 — all tiers green, 156/156 Supplier suite 2026-05-19 17:31:15 +03:00
Дмитрий efd3e73aa2 fix(supplier): manage-project.js — drop wrong status-switch click + recon live-smoke
Task 4 live-smoke выявил: единственный .el-switch формы — include/exclude
регионов (regions_reverse), НЕ статус active/paused. Старый код кликал его
по dto.active → ошибочно ставил regions_reverse. Статус — дефолт портала
(active), UI-switch для него нет → switch-блок удалён.

recon-doc 2026-05-19-rt-project-form-locators.md: +секция Live-smoke
(domain-формат валидируется, multi-source save = N проектов, switch = regions,
type/tab re-render); row 6 исправлен.
2026-05-19 17:31:15 +03:00
Дмитрий 0f1b604554 fix(supplier): manage-project.js robustness — conditional type/tab clicks + diag dump
Найдено при Task 4 live-smoke form-канала:
- type-select и вкладка «Список» кликались безусловно → re-click уже-активного
  значения ремоунтит Element UI tab-pane (textarea детачится). Теперь кликаем
  только при реальной смене значения + waitForTimeout после смены типа.
- defensive: проверка непустого textarea после fill content.
- diag: на status!=OK дамп фактически отправленного rt-project-save body в stderr.
2026-05-19 17:31:14 +03:00
Дмитрий 48d7303963 fix(supplier): manage-project.js — text-only platform locator + exact endsWith URL match (reviewer Critical+Important) 2026-05-19 17:31:13 +03:00
Дмитрий b9e72e6231 feat(supplier): rewrite manage-project.js for Element UI + intercept rt-project-save response for external_id
- fillForm rewritten to label-for locators (.el-form-item:has([for="..."])) from recon 2026-05-19
- createOp: external_id from page.waitForResponse('rt-project-save') body, not DOM
- updateOp: same save endpoint intercept; row found by data-id or text
- listOp: Vuex state strategy 1, DOM scrape strategy 2, empty array fallback
- Known gaps (JSDoc + stderr warnings): workdays not in add-project form (portal default);
  regions require id->name mapping (skipped in Tier-2 MVP, logged to stderr)
- Test: HTTP fixture server serves rt-form-element-ui.html + handles /admin/visit/rt-project-save
- Fixture: .v-dialog--active wrapper + 10 .el-form-item (label[for=...]) + type-select popup in body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:31:13 +03:00
Дмитрий 80c5f6289a docs(discovery): rt-project form locators recon (Element UI + Vuetify dialog, 10 fields) 2026-05-19 17:31:12 +03:00
Дмитрий 895975482d test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:31:11 +03:00
Дмитрий e81cd8ed2c test(supplier): lock HTTP-200-without-Content-Type contract (no login-detect false-positive) 2026-05-19 17:31:11 +03:00
Дмитрий bff5faf02b feat(supplier): detect HTTP-200 HTML login page → force refresh+retry (defense-in-depth) 2026-05-19 17:30:54 +03:00
Дмитрий 8df5a3fe00 docs(supplier): plan for migration follow-up — HTTP-200 login detect + form rewrite + 3-tier smoke 2026-05-19 17:29:52 +03:00
140 changed files with 19648 additions and 1212 deletions
+43
View File
@@ -0,0 +1,43 @@
---
name: billing-audit
description: Аудит денежной корректности биллинг-кода Лидерры — money-инварианты при правке/ревью списаний, тарифов и баланса. Используй при «проверь списание», «аудит биллинга», «не теряются ли копейки», «идемпотентно ли списание», «корректна ли тарифная ступень», «что значит дрейф CsvReconcile», «провенанс charge_source». НЕ для моделирования процесса (process-modeling), поиска узких мест (process-analysis), security-аудита (D3), РСБУ/налогов (ru-tax-accounting), метрик выручки (product-management).
---
# Billing Audit — аудит денежной корректности биллинга Лидерры
Проектный скил раздела C6 карты «Финансы — биллинг и тарификация». Проверяет
**денежные инварианты** биллинг-подсистемы при правке или ревью кода. Объект —
корректность *начисления* (не процесс, не безопасность, не учёт/налоги).
## Когда использовать
- Правка/ревью кода в `app/app/Services/Billing/**`, `app/app/Jobs/Supplier/CsvReconcileJob.php`,
моделей `PricingTier`/`LeadCharge`, контроллеров биллинга.
- Вопрос «безопасно ли это денежно?» по списанию, тарифу, балансу, сверке.
## Процедура аудита (5 инвариантов)
Полный чек-лист с проверками и ссылками на файлы — `references/invariants.md`.
1. **Сохранение суммы** — все денежные операции через `bcmath` (bcadd/bcsub/bcmul/bcdiv,
scale фиксирован), никаких float; prepaid→₽ конвертация без потери копеек.
2. **Идемпотентность списания** — один лид = одно списание; повтор/ретрай джоба
не дублирует начисление (проверить уникальный ключ / advisory-lock / upsert).
3. **Корректность тарифной ступени**`PricingTierResolver` выбирает верную из 7
ступеней по объёму; границы ступеней (включительно/исключительно) однозначны.
4. **Дрейф сверки**`CsvReconcileJob` порог >5%: что сравнивается, что значит дрейф,
куда смотреть (рассинхрон поставки vs ошибка тарифа).
5. **Провенанс charge_source** — каждое списание имеет прослеживаемый источник
(`charge_source`); ручные/авто/CSV-восстановленные различимы.
## Границы
-`process-modeling` #52 / `process-analysis` #53 — те про *поток/процесс*; billing-audit про *деньги в коде*.
- ≠ D3 audit-security (#39/#40) — те про *безопасность*; billing-audit про *денежную корректность*.
-`ru-tax-accounting` #63 — тот про *учёт/налоги* (выход биллинга → налоговая база); billing-audit про *начисление*.
-`product-management:metrics-review` #42 — тот про *метрики выручки*; billing-audit про *корректность*.
## Связано
- Reuse: Boost #10 (модели), Pest #18 (тесты инвариантов), Larastan #12 (bcmath/без float), Sentry #34 / Redis #35 (runtime/очередь).
- ADR-012 (граница finance-tooling C6/C7).
@@ -0,0 +1,22 @@
{
"skill": "billing-audit",
"positive": [
"проверь корректность списания за лид",
"аудит денежной логики биллинга",
"не теряются ли копейки в prepaid→рублёвом балансе",
"идемпотентно ли списание при ретрае",
"правильно ли резолвится тарифная ступень",
"что значит дрейф >5% в CsvReconcile",
"проверь провенанс charge_source",
"ревью PricingTierResolver на ошибки округления",
"ledger двойной баланс — где может утечь сумма",
"audit charge invariants before merge"
],
"near_miss": [
{"prompt": "смоделируй BPMN процесса списания", "expect": "process-modeling #52"},
{"prompt": "где узкое место в воронке оплат", "expect": "process-analysis #53"},
{"prompt": "security-аудит платёжного эндпоинта", "expect": "D3 audit-security / Semgrep"},
{"prompt": "посчитай РСБУ-проводки по выручке", "expect": "ru-tax-accounting #63"},
{"prompt": "метрика MRR за месяц", "expect": "product-management metrics-review #42"}
]
}
@@ -0,0 +1,46 @@
# Денежные инварианты биллинга Лидерры — чек-лист аудита
Объект-файлы (на момент 20.05.2026):
- `app/app/Services/Billing/PricingTierResolver.php` — резолюция 7 ступеней (pure).
- `app/app/Services/Billing/LedgerService.php` — двойной баланс prepaid→₽ (bcmath).
- `app/app/Services/Billing/BillingTopupService.php` — пополнение.
- `app/app/Services/Billing/ChargeResult.php` — DTO результата списания.
- `app/app/Models/PricingTier.php`, `app/app/Models/LeadCharge.php`.
- `app/app/Repositories/PricingTierRepository.php`.
- `app/app/Jobs/Supplier/CsvReconcileJob.php` — hourly сверка, алерт дрейфа >5%.
- `app/app/Http/Controllers/Api/{AdminPricingTiersController,AdminBillingController,BillingController,TenantChargesController}.php`.
## I1. Сохранение суммы (bcmath, без float)
- [ ] Все арифметические операции с деньгами — `bcadd`/`bcsub`/`bcmul`/`bcdiv`/`bccomp` с явным `scale`.
- [ ] Нет `+`/`-`/`*`/`/` над денежными значениями (Larastan/grep на float-арифметику в Billing).
- [ ] prepaid→₽: конвертация округляет детерминированно (TRUNC/округление вниз в пользу tenant — свериться с кодом), сумма prepaid + ₽ не «исчезает».
- [ ] Денежные колонки — целочисленные копейки или DECIMAL, не float/double.
## I2. Идемпотентность списания
- [ ] Один лид → одно списание: уникальность по (lead_id) или advisory-lock в `LedgerService`.
- [ ] Ретрай `ImportLeadsJob`/`CsvReconcileJob` не создаёт дубль `lead_charges`.
- [ ] Транзакция + `lockForUpdate` на балансе при мутации (TOCTOU — см. Sprint 3 lockForUpdate).
## I3. Корректность тарифной ступени
- [ ] `PricingTierResolver` выбирает ступень по объёму `delivered_in_month` верно на границах.
- [ ] Границы ступеней непрерывны (нет дыр/перекрытий между 7 ступенями).
- [ ] Pest покрывает граничные значения (ступень N → N+1).
## I4. Дрейф сверки CsvReconcile
- [ ] Порог >5% — что сравнивается (поставка поставщика vs начислено) → `supplier_csv_reconcile_log`.
- [ ] Дрейф = рассинхрон поставки (норм) ИЛИ ошибка тарифа (баг) — различить по `charge_source`.
## I5. Провенанс charge_source
- [ ] Каждое `lead_charges.charge_source` заполнено и прослеживаемо.
- [ ] Авто/ручное/CSV-восстановленное (`recovered_from_csv_at`) различимы.
## Reuse-инструменты
Boost #10 (Eloquent-introspection), Pest #18 + pest-parallel-debugger (тесты + race),
Larastan #12 (статанализ bcmath), Sentry MCP #34 (runtime списаний), Redis MCP #35 (очередь сверки), context7 #60 (доки bcmath).
+2 -1
View File
@@ -24,11 +24,12 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
1. **Determine period**: ask user «за какой период» or default to «since last brain-retro» (find latest `docs/observer/notes/YYYY-MM-DD-brain-retro-*.md`).
2. **Read evidence**: glob `docs/observer/episodes-YYYY-MM.jsonl` for the period; read all lines as JSON.
3. **Read optional notes**: glob `docs/observer/notes/*.md` filtered by date.
4. **Update read-counter**: bump `docs/observer/.read-counter.json` `last_read_at` to now, increment `read_count_last_period`. (Side-effect — used by C3 observer-of-observer.)
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This atomically bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer for 54-week self-prune detection.)
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`.
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
9. **Report to user**: high-signal summary.
## Output anatomy
@@ -0,0 +1,62 @@
---
name: laravel-backend-patterns
description: Backend-конвенции Лидерры (Laravel 13) — как писать controller→service→job, RLS-aware Eloquent, деньги через bcmath/LedgerService, идемпотентные джобы, partition-aware запросы. Используй при «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи. НЕ для generic-паттернов (architecture-patterns #38), аудита денег (billing-audit #62), РСБУ/налогов (ru-tax-accounting), security-аудита (D3).
---
# Laravel Backend Patterns — конвенции backend-кода Лидерры
Проектный скил, который описывает **как здесь пишут backend**, а не как рекомендует generic-Laravel.
При scaffolding новой фичи или ревью кода — сверяться с пятью конвенциями ниже.
Детальные примеры с образцами кода и антипаттернами — в `references/conventions.md`.
## 1. Слоистость: Controller → FormRequest → Service → Job
Контроллер тонкий: принимает FormRequest, делегирует Service, возвращает JSON-ответ.
Бизнес-логика — в Service; асинхронная работа — в Job.
Слои зафиксированы в `app/deptrac.yaml` (13 слоёв, pre-commit gate job 10).
Подробнее: `references/conventions.md` §1.
## 2. RLS-aware Eloquent и middleware `tenant`
Middleware `SetTenantContext` оборачивает HTTP-запрос в транзакцию и выполняет
`SET LOCAL app.current_tenant_id = X`, обеспечивая RLS-изоляцию между tenant'ами.
**КРИТИЧНО**: очередные джобы выполняются под ролью `crm_supplier_worker` (BYPASSRLS),
поэтому RLS не фильтрует. Каждый запрос в джобе **обязан** содержать явный
`where('tenant_id', $tenantId)` или устанавливать `SET LOCAL` вручную внутри транзакции.
Подробнее: `references/conventions.md` §2.
## 3. Деньги — только через bcmath и LedgerService
Все денежные операции — `bcadd` / `bcsub` / `bcmul` / `bcdiv` / `bccomp` со строковыми операндами
и фиксированным `scale`. Никаких операторов `+` / `-` / `*` / `/` над деньгами, никакого `float`.
Точка входа для биллингового списания — `LedgerService::chargeForDelivery()`.
Аудит денежных инвариантов кода — скил `billing-audit` (#62); здесь — только конвенция написания.
Подробнее: `references/conventions.md` §3.
## 4. Идемпотентные джобы через advisory lock
Повторный запуск джоба не должен дублировать результат.
Паттерн: `pg_advisory_xact_lock(composite_bigint)` внутри транзакции — сериализует
конкурентные обработки одного (tenant_id, source_crm_id). Дополнительно: `lockForUpdate`
на строку Tenant защищает баланс от TOCTOU при конкурентных списаниях.
Подробнее: `references/conventions.md` §4.
## 5. Partition-aware запросы для `deals` и `supplier_lead_costs`
Таблицы `deals` и `supplier_lead_costs` секционированы по `RANGE (received_at)`.
Запросы к этим таблицам должны включать условие по `received_at` (или `created_at`
для `supplier_lead_costs`) — это включает pruning и предотвращает full-scan всех партиций.
Подробнее: `references/conventions.md` §5.
## Связано
- `billing-audit` #62 — аудит денежной корректности (I1–I5 инварианты).
- `architecture-patterns` #38 — общие паттерны архитектуры (не Лидерра-специфика).
- Boost #10 — Eloquent introspection, документация Laravel 13.
- Larastan #12 — статанализ PHP (ловит float-арифметику на деньгах).
- ADR-005 — deptrac architecture-fitness gate.
@@ -0,0 +1,10 @@
{
"skill": "laravel-backend-patterns",
"cases": [
{"prompt": "как написать контроллер для новой backend-фичи в Лидерре", "should_trigger": true},
{"prompt": "как правильно списать деньги в джобе под crm_supplier_worker", "should_trigger": true},
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
{"prompt": "опиши Clean Architecture в общем", "should_trigger": false, "expected": "architecture-patterns"},
{"prompt": "учёт выручки по РСБУ", "should_trigger": false, "expected": "ru-tax-accounting"}
]
}
@@ -0,0 +1,280 @@
# Backend-конвенции Лидерры — детальный справочник
Образцы ниже — реальный код из `app/` (Laravel 13, PHP 8.3).
Указаны конкретные `file:line` на момент 20.05.2026.
---
## §1. Слоистость: Controller → FormRequest → Service → Job
### Правило
Контроллер принимает FormRequest (валидация), делегирует Service (бизнес-логика),
при необходимости Service dispatch'ит Job (асинхрон). Контроллер не содержит бизнес-логики.
Слои задокументированы в `app/deptrac.yaml` — 13 слоёв:
Controller, Request, Resource, Middleware, Service, Job, Console, Repository,
Model, Mail, Rule, Exception, Provider.
Допустимые направления зависимостей — только вниз по иерархии (deptrac gate, lefthook job 10).
### Образец из кода
`app/app/Http/Controllers/Api/ProjectController.php:8790` — контроллер тонкий:
```php
/** POST /api/projects */
public function store(StoreProjectRequest $request): JsonResponse
{
$project = $this->projects->create($request->user()->tenant, $request->validated());
return response()->json(['data' => new ProjectResource($project)], 201);
}
```
`app/app/Http/Requests/StoreProjectRequest.php:1844` — вся валидация в FormRequest:
```php
public function rules(): array
{
$base = [
'name' => ['required', 'string', 'max:255'],
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
'regions' => ['present', 'array'],
'regions.*' => ['integer', 'between:1,89'],
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
];
// ... conditional rules by signal_type
return $base;
}
```
`app/app/Services/Billing/LedgerService.php` — бизнес-логика в Service.
`app/app/Jobs/ProcessWebhookJob.php` — асинхрон в Job.
### Антипаттерн
```php
// ПЛОХО: бизнес-логика в контроллере
public function store(Request $request): JsonResponse
{
$tier = PricingTier::where('min_leads', '<=', $count)->orderBy('min_leads', 'desc')->first();
$price = $tier->price_per_lead_kopecks * $count; // float-арифметика + логика тира прямо здесь
Deal::create([...]);
return response()->json(['ok' => true]);
}
```
---
## §2. RLS-aware Eloquent и middleware `tenant`
### Правило
Middleware `SetTenantContext` (`app/app/Http/Middleware/SetTenantContext.php`) оборачивает
каждый HTTP-запрос в транзакцию и выполняет `SET LOCAL app.current_tenant_id = X`,
после чего RLS-политики PostgreSQL автоматически фильтруют строки по tenant.
**КРИТИЧНО для джобов**: очередные джобы Laravel выполняются в отдельном процессе вне
HTTP-стека. Роль `crm_supplier_worker` (connection `pgsql_supplier`) имеет атрибут
BYPASSRLS — RLS-политики для неё **не применяются**. Любой запрос в таком джобе без
явного `where('tenant_id', $tenantId)` вернёт строки всех tenant'ов.
Правило: в каждом джобе либо устанавливай `SET LOCAL` внутри транзакции (паттерн
`ProcessWebhookJob`/`ImportLeadsJob`), либо добавляй явный `where('tenant_id', ...)`.
### Образец из кода
`app/app/Http/Middleware/SetTenantContext.php:3643` — HTTP-путь:
```php
DB::beginTransaction();
try {
DB::statement('SET LOCAL app.current_tenant_id = ' . $tenantId);
$response = $next($request);
DB::commit();
return $response;
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
```
`app/app/Jobs/ImportLeadsJob.php:9296` — джоб устанавливает `SET LOCAL` вручную:
```php
return DB::transaction(function (): ?ImportLog {
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
return ImportLog::query()->find($this->importLogId);
});
```
`app/app/Jobs/ProcessWebhookJob.php:8086` — аналогичный паттерн в webhook-джобе:
```php
DB::transaction(function () use ($duplicateDetector): void {
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
$tenant = Tenant::query()
->whereKey($this->tenantId)
->lockForUpdate()
->first();
```
### Антипаттерн
```php
// ПЛОХО: джоб под crm_supplier_worker без SET LOCAL и без where tenant_id
// → вернёт все строки всех tenant'ов (BYPASSRLS не фильтрует)
public function handle(): void
{
$logs = ImportLog::query()->where('status', 'pending')->get(); // ВСЕ tenant'ы!
}
```
---
## §3. Деньги — только через bcmath и LedgerService
### Правило
Все арифметические операции с деньгами (рубли, копейки) — исключительно через
функции `bcmath` с явным `scale`. Операнды передаются строками.
Никаких PHP `float`, никакого `+` / `-` / `*` / `/` над денежными значениями.
Точка входа для списания за лид — `LedgerService::chargeForDelivery()`.
Этот метод реализует dual-balance flow (prepaid-лиды → `balance_leads`, рубли → `balance_rub`).
Вызывается **внутри открытой транзакции** с `lockForUpdate(Tenant)` — см. §4.
Аудит денежных инвариантов (I1–I5) — скил `billing-audit` (#62). Здесь — конвенция написания.
### Образец из кода
`app/app/Services/Billing/LedgerService.php:6465` — конвертация копеек в рубли:
```php
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
```
`app/app/Services/Billing/LedgerService.php:124125` — сравнение балансов:
```php
$balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
return 'rub';
}
```
### Антипаттерн
```php
// ПЛОХО: float-арифметика теряет копейки
$price = $tier->price_per_lead_kopecks / 100; // float
$newBalance = $tenant->balance_rub - $price; // потеря точности при накоплении
```
---
## §4. Идемпотентные джобы через advisory lock
### Правило
Повторный запуск джоба (ретрай, краш, дубль cron) не должен создавать дублирующие
записи. Паттерн: `pg_advisory_xact_lock(bigint)` внутри транзакции сериализует все
конкурентные обработки одного (tenant_id, source_crm_id).
Дополнительно для мутаций баланса: `lockForUpdate` на строку Tenant — защита от
TOCTOU (между чтением баланса и его обновлением другой воркер не должен изменить значение).
### Образец из кода
`app/app/Jobs/ProcessWebhookJob.php:293296` — advisory lock перед upsert:
```php
// pg_advisory_xact_lock(bigint): верхние 32 бита = tenant_id, нижние 32 = source_crm_id
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
```
`app/app/Services/Import/HistoricalImportService.php:145147` — тот же паттерн в сервисе:
```php
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5)
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
```
`app/app/Jobs/RouteSupplierLeadJob.php:210213` — lockForUpdate на Tenant перед списанием:
```php
$tenant = Tenant::query()
->whereKey($project->tenant_id)
->lockForUpdate()
->firstOrFail();
```
Для overlap-защиты долгоживущих джобов (cron) — `Cache::lock` (Redis):
`app/app/Jobs/Supplier/CsvReconcileJob.php:6974`:
```php
$lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
if (! $lock->get()) {
Log::info('csv_reconcile.skipped_overlap');
return;
}
```
### Антипаттерн
```php
// ПЛОХО: нет lock — два конкурентных воркера создают два deal для одного vid
$existing = Deal::where('source_crm_id', $vid)->where('tenant_id', $tenantId)->first();
if (!$existing) {
Deal::create([...]); // race condition: оба воркера видят null и оба создают
}
```
---
## §5. Partition-aware запросы для `deals` и `supplier_lead_costs`
### Правило
Таблицы `deals` и `supplier_lead_costs` секционированы по `PARTITION BY RANGE (received_at)`.
Запросы должны содержать условие по `received_at` (ключ партиционирования) — это позволяет
PostgreSQL выполнять partition pruning и не сканировать все партиции.
Запрос без `WHERE received_at ...` делает full-scan всех партиций.
### Образец из кода
`db/schema.sql:1658` — партиционирование `deals`:
```sql
) PARTITION BY RANGE (received_at);
```
`db/schema.sql:2361` — партиционирование `supplier_lead_costs`:
```sql
) PARTITION BY RANGE (received_at);
```
`app/app/Services/DuplicateDetector.php:49` — запрос к `deals` с ключом партиции:
```php
->where('received_at', '>=', $windowStart)
```
`app/app/Jobs/Supplier/CsvReconcileJob.php:113` — запрос к `supplier_leads` с ключом:
```php
->where('received_at', '>=', $windowStart)
```
### Антипаттерн
```php
// ПЛОХО: запрос к deals без received_at — full-scan всех партиций
$deals = Deal::where('tenant_id', $tenantId)
->where('phone', $phone)
->get(); // сканирует deals_2026_05, deals_2026_06, ... все партиции
```
+43
View File
@@ -0,0 +1,43 @@
---
name: ru-tax-accounting
description: Контекст РСБУ и налогов РФ (НК РФ, НДС/УСН) применительно к SaaS-выручке Лидерры за лиды. Используй при «учёт выручки по РСБУ», «НДС или УСН», «налоговая база по выручке», «налогооблагаемое событие», «выгрузка для бухгалтера», «проводки РСБУ». НЕ для денежной корректности кода (billing-audit), US-GAAP-отчётности (finance plugin), договоров (D1 право), ПДн (D2), сверки с банком (finance reconciliation).
---
# RU Tax & Accounting — РСБУ/НК РФ контекст для выручки Лидерры
Проектный скил раздела C7 карты «Финансы — бухгалтерия и налоги». Переводит
billing-выручку (выход C6) в **российский учётно-налоговый контекст** (РСБУ + НК РФ).
Закрывает gap, который US-GAAP-плагин `finance` (#61) не покрывает.
## Когда использовать
- Вопрос «как это учесть/обложить по РФ-правилам?» по выручке/пополнениям/возвратам.
- Подготовка выгрузок/пояснений для бухгалтера из billing-данных.
- Определение налогооблагаемого события и налоговой базы.
## Содержание (см. references/ru-tax-context.md)
1. **Налоговые режимы РФ** — НДС (ОСНО) vs УСН (доходы / доходы-расходы); что применимо к SaaS за лиды.
2. **Налогооблагаемое событие** — пополнение баланса (аванс) vs списание за лид (реализация) vs возврат.
3. **Маппинг billing→база**`lead_charges`/`LedgerService` → выручка → налоговая база; роль `charge_source`.
4. **РСБУ vs управленческий** — отличие от US-GAAP-отчётов плагина finance; первичка/документы.
5. **Выгрузки для бухгалтера** — какие данные и в каком разрезе извлечь (Boost/Pest как инструменты выгрузки).
## Границы
-`billing-audit` #62 — тот про *корректность начисления в коде*; ru-tax про *учёт/налог результата*.
-`finance` plugin #61 — тот US-GAAP-механика (проводки/отчёты/сверка); ru-tax — РФ-специфика РСБУ/НК.
- ≠ D1 «Юриспруденция/договорная» — там договоры/право; ru-tax — налоги.
- ≠ D2 «Защита ПДн (152-ФЗ)» — там персональные данные; ru-tax — налоги.
## Ограничение
Бухгалтерия компании ведётся вне dev-репо (1С/аутсорс). Скил даёт **контекст и выгрузки**,
не заменяет бухгалтера и не является налоговой консультацией. Реальный платёжный
провайдер — DEFERRED (Б-1).
## Связано
- Вход: выручка из C6 (`lead_charges`, `LedgerService`).
- Reuse: data-scientist #49 (финмодели), Boost #10 / Pest #18 (выгрузка), finance plugin #61 (US-механика).
- ADR-012 (граница finance-tooling C6/C7).
@@ -0,0 +1,21 @@
{
"skill": "ru-tax-accounting",
"positive": [
"как учесть выручку за лиды по РСБУ",
"НДС или УСН для SaaS-выручки",
"переведи billing-выручку в налоговую базу",
"какое налогооблагаемое событие при пополнении баланса",
"выгрузка lead_charges для бухгалтера",
"проводки по РСБУ за списания",
"налоговый режим для подписочной выручки портала",
"что с НДС при возврате на баланс tenant",
"налоговая база УСН доходы по выручке за лиды"
],
"near_miss": [
{"prompt": "проверь идемпотентность списания", "expect": "billing-audit #62"},
{"prompt": "US-GAAP financial statement", "expect": "finance plugin #61 financial-statements"},
{"prompt": "договор с поставщиком лидов", "expect": "D1 юриспруденция"},
{"prompt": "обработка ПДн при выгрузке", "expect": "D2 ПДн 152-ФЗ"},
{"prompt": "сверка ledger с банком", "expect": "finance plugin #61 reconciliation"}
]
}
@@ -0,0 +1,40 @@
# РСБУ / НК РФ — контекст для выручки Лидерры за лиды
> Не налоговая консультация. Контекст для подготовки данных бухгалтеру.
## 1. Налоговые режимы РФ
- **ОСНО + НДС (НК РФ гл. 21)** — НДС 20% на реализацию услуг РФ. Электронные/рекламные
услуги — проверить место реализации и применимые льготы.
- **УСН (НК РФ гл. 26.2)** — «доходы» (6%) или «доходы минус расходы» (15%). Без НДС
(кроме исключений). Типичный режим для раннего SaaS.
- Применимый режим зависит от регистрации ООО (Б-1) — до закрытия Б-1 фиксируем как параметр.
## 2. Налогооблагаемое событие
- **Пополнение баланса** = аванс (предоплата). По НДС — момент определения базы может
возникать на аванс; по УСН-доходы — доход по поступлению (кассовый метод).
- **Списание за лид** = реализация услуги (закрытие аванса).
- **Возврат на баланс / с баланса** = корректировка базы.
- Различать по `lead_charges.charge_source` и операциям `LedgerService`.
## 3. Маппинг billing → налоговая база
| Billing-сущность | Учётный смысл |
|---|---|
| Пополнение (`BillingTopupService`) | Аванс / поступление |
| Списание (`LedgerService`, `lead_charges`) | Реализация (выручка) |
| `delivered_in_month` (`tenants`) | Объём для tier — не налог напрямую |
| Возврат | Корректировка |
## 4. РСБУ vs управленческий / US-GAAP
- РСБУ — российский план счетов, первичные документы (акт/УПД), кассовый/начисление.
- US-GAAP-скилы плагина `finance` (#61) — иная форма (income statement / balance sheet);
применимы для *внутренней управленки*, не для РФ-отчётности.
## 5. Выгрузки для бухгалтера
- Реестр списаний за период: `lead_charges` (period, tenant, сумма, charge_source).
- Реестр пополнений: операции `LedgerService` / `BillingTopupService`.
- Инструменты выгрузки: Boost #10 (Eloquent/SQL), Pest #18 (фикстуры/проверки), `BillingSummaryProvider` (готовый отчёт-провайдер).
+4 -1
View File
@@ -98,7 +98,10 @@ paths = [
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
'''app/tests/Frontend/.*\.(spec|test)\.ts''',
# Settings-вкладки с фиктивными mock-данными (профиль/сессии — UI-разводка)
'''app/resources/js/views/settings/.*\.vue'''
'''app/resources/js/views/settings/.*\.vue''',
# Test fixtures for the observer PII filter — contains synthetic JWT / AWS /
# Yandex tokens that the filter is supposed to redact. Not real secrets.
'''tools/observer-pii-filter\.test\.mjs'''
]
regexTarget = "match"
regexes = [
+19 -4
View File
File diff suppressed because one or more lines are too long
@@ -10,6 +10,9 @@ use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\SupplierProject;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Support\RussianRegions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -142,4 +145,114 @@ final class AdminSupplierIntegrationController extends Controller
return response()->json(['resolved' => true, 'external_id' => $found]);
}
/**
* Глобальный режим экспорта проектов поставщику (Plan 4 Task 1).
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
*/
public function getExportMode(): JsonResponse
{
return response()->json(['mode' => SupplierExportMode::current()]);
}
public function setExportMode(Request $request): JsonResponse
{
$data = $request->validate([
'mode' => ['required', 'in:online,batch'],
]);
DB::table('system_settings')->updateOrInsert(
['key' => 'supplier_export_mode'],
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
);
return response()->json(['mode' => $data['mode']]);
}
/**
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot
* projects tenants) + дата последней поставки лида.
*/
public function projectsIndex(): JsonResponse
{
$rows = DB::table('supplier_projects as sp')
->select([
'sp.id',
'sp.platform',
'sp.signal_type',
'sp.unique_key',
'sp.subject_code',
'sp.supplier_external_id',
'sp.current_limit',
'sp.inactive_since',
])
->orderBy('sp.unique_key')
->orderBy('sp.subject_code')
->orderBy('sp.platform')
->get();
$projects = $rows->map(function ($sp): array {
$orderers = DB::table('project_supplier_links as psl')
->join('projects as p', 'p.id', '=', 'psl.project_id')
->join('tenants as t', 't.id', '=', 'p.tenant_id')
->where('psl.supplier_project_id', $sp->id)
->distinct()
->pluck('t.organization_name')
->all();
$lastDelivery = DB::table('supplier_leads')
->where('supplier_project_id', $sp->id)
->max('received_at');
$subjectCode = $sp->subject_code !== null ? (int) $sp->subject_code : null;
return [
'id' => (int) $sp->id,
'platform' => $sp->platform,
'signal_type' => $sp->signal_type,
'unique_key' => $sp->unique_key,
'subject_code' => $subjectCode,
'subject_name' => $subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? null)
: 'РФ',
'current_limit' => (int) $sp->current_limit,
'supplier_external_id' => $sp->supplier_external_id,
'inactive_since' => $sp->inactive_since,
'orderers' => $orderers,
'last_delivery_at' => $lastDelivery,
];
});
return response()->json(['projects' => $projects->all()]);
}
/**
* Plan 4 Task 2: bulk-delete выбранных supplier_projects.
* Сначала на портале (deleteProject), затем локально (pivot снимается CASCADE).
* Сбой по строке не прерывает batch, копится в failures[].
*/
public function projectsDestroy(Request $request, SupplierPortalClient $client): JsonResponse
{
$data = $request->validate([
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['integer'],
]);
$deleted = 0;
$failures = [];
foreach (SupplierProject::whereIn('id', $data['ids'])->get() as $sp) {
try {
if ($sp->supplier_external_id !== null) {
$client->deleteProject((int) $sp->supplier_external_id);
}
$sp->delete();
$deleted++;
} catch (\Throwable $e) {
$failures[] = ['id' => $sp->id, 'error' => $e->getMessage()];
}
}
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
}
}
+14 -11
View File
@@ -12,8 +12,10 @@ use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -86,6 +88,8 @@ class RouteSupplierLeadJob implements ShouldQueue
DuplicateDetector $duplicateDetector,
NotificationService $notifier,
LedgerService $ledger,
LeadDistributor $distributor,
RegionTagResolver $tagResolver,
): void {
$lead = SupplierLead::findOrFail($this->supplierLeadId);
@@ -108,20 +112,19 @@ class RouteSupplierLeadJob implements ShouldQueue
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
$lead->update(['supplier_project_id' => $supplier->id]);
$matched = $router->matchEligibleProjects($supplier, (string) $lead->phone);
$matched = $router->matchEligibleProjects($supplier);
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
$createdCount = 0;
$failures = [];
foreach ($matched as $project) {
foreach ($selected as $project) {
try {
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) {
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
$createdCount++;
}
} catch (Throwable $e) {
// Per-Project failure isolation (Plan 2 code-review Important).
// Sharing-model: один сбой проекта не должен абортить routing других tenant'ов.
// Логируем и продолжаем; final failed() callback зафиксирует общий проблемный лид
// только если ВСЕ Projects упали (через handle() rethrow ниже).
$failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
Log::warning('supplier_lead.per_project_routing_failed', [
'supplier_lead_id' => $lead->id,
@@ -132,9 +135,7 @@ class RouteSupplierLeadJob implements ShouldQueue
}
}
// Если ВСЕ Projects упали (а matched был непустой) — пробрасываем последнюю ошибку,
// чтобы failed() callback сработал и проблема ушла в failed_webhook_jobs.
if ($matched->isNotEmpty() && $createdCount === 0 && count($failures) === $matched->count()) {
if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) {
throw new RuntimeException(
'All eligible projects failed routing for supplier_lead='.$lead->id.
'; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
@@ -199,9 +200,10 @@ class RouteSupplierLeadJob implements ShouldQueue
DuplicateDetector $duplicateDetector,
NotificationService $notifier,
LedgerService $ledger,
?int $subjectCode,
): bool {
try {
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool {
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -252,6 +254,7 @@ class RouteSupplierLeadJob implements ShouldQueue
'phones' => $phones,
'status' => 'new',
'received_at' => $receivedAt,
'subject_code' => $subjectCode,
]);
$master = $duplicateDetector->findMaster(
+295 -144
View File
@@ -14,47 +14,53 @@ use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Services\Supplier\SupplierQuotaAllocator;
use App\Support\RussianRegions;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use stdClass;
use Throwable;
/**
* Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru.
* Daily 18:00 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru
* (расписание перенесено 20:30 18:00, см. routes/console.php).
*
* Алгоритм (per spec §4.3):
* 1. Итерация по всем активным (inactive_since IS NULL) supplier_projects.
* 2. Для каждого:
* a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id.
* b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions.
* c. Вызвать SupplierQuotaAllocator::allocate() pure distribution.
* d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff.
* e. saveProject() при supplier_external_id=null, иначе updateProject().
* f. Записать audit row в supplier_sync_log.
* 3. Failure-handling:
* - SupplierAuthException SupplierCriticalAlertMail('sticky_auth') + Sentry + throw.
* - SupplierTransientException log + continue. После 50 подряд mass_transient alert + break.
* - SupplierClientException log + continue.
* 4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00).
* Алгоритм (план 3 Task 5 переработан: one-group-per-identifier):
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
* 2. Сгруппировать по (signal_type, identifier) БЕЗ subject_code:
* - identifier = buildUniqueKeyAgnostic() (site/call signal_identifier; sms+keyword sender+keyword; sms sender).
* - platforms = resolvePlatforms() (site/call B1+B2+B3; sms+keyword B2+B3; sms B3).
* - merged_regions = union(project.regions) по всем проектам группы.
* Если хотя бы один проект имеет regions=[] («Вся РФ»), merged_regions=[].
* 3. Для каждой группы:
* - eligible-today проекты группы (workday-маска на завтра).
* - order = computeOrder($eligibleLimits); workdays = union.
* - tag = name региона если один, иначе «РФ».
* - Найти существующие supplier_projects (unique_key, signal_type, platform) без subject_code-фильтра:
* - Нет saveProjectMultiFlag [platform id] upsert supplier_projects (subject_code=null).
* - Есть partial-set recovery + updateProject каждого с актуальными regions/limit.
* - Pivot: project × supplier_project INSERT ... ON CONFLICT DO NOTHING (subject_code=null).
* 4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff сохранены.
*
* NOTE про connection: Job's $connection это queue connection, не DB. Используем
* Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning).
* Портальное ограничение: один identifier = одна группа B1/B2/B3 (status=Doubles на дублирование).
* Поэтому все регионы проекта передаются одним списком portal фильтрует оба одновременно.
*
* NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости.
*
* Spec:
* - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
* - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4
* - docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
* - docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 5
*/
class SyncSupplierProjectsJob implements ShouldQueue
{
@@ -68,33 +74,80 @@ class SyncSupplierProjectsJob implements ShouldQueue
private SupplierProjectChannel $channel;
private SupplierPortalClient $client;
public function handle(?SupplierProjectChannel $channel = null): void
{
$this->channel = $channel ?? app(SupplierProjectChannel::class);
$this->client = app(SupplierPortalClient::class);
$consecutiveTransient = 0;
$projects = SupplierProject::on(self::DB_CONNECTION)
->whereNull('inactive_since')
// 1. Load active Лидерра-projects via pgsql_supplier
/** @var Collection<int, Project> $projects */
$projects = Project::on(self::DB_CONNECTION)
->where('is_active', true)
->whereNull('archived_at')
->orderBy('id')
->get();
foreach ($projects as $sp) {
// 2. Group by (signal_type, identifier) — no subject_code split.
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
// group key => [ 'signal_type', 'identifier', 'merged_regions', 'platforms', 'projects' => [...] ]
/** @var array<string, array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>}> $groups */
$groups = [];
foreach ($projects as $project) {
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
if ($platforms === []) {
continue;
}
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
$key = $project->signal_type.'|'.$identifier;
if (! isset($groups[$key])) {
$groups[$key] = [
'signal_type' => (string) $project->signal_type,
'identifier' => $identifier,
'merged_regions' => [],
'has_all_russia' => false,
'platforms' => $platforms,
'projects' => [],
];
}
// Merge regions — union across all projects in this group.
// If any project has empty regions ("Вся РФ"), the whole group becomes "Вся РФ".
if (! $groups[$key]['has_all_russia']) {
$projectRegions = array_map('intval', (array) ($project->regions ?? []));
if ($projectRegions === []) {
$groups[$key]['has_all_russia'] = true;
$groups[$key]['merged_regions'] = [];
} else {
$groups[$key]['merged_regions'] = array_values(array_unique(
array_merge($groups[$key]['merged_regions'], $projectRegions)
));
}
}
$groups[$key]['projects'][] = $project;
}
// 3. Sync each group
foreach ($groups as $group) {
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
Log::warning('supplier.sync.time_budget_reached', [
'processed_until' => $sp->id,
'group' => $group['identifier'],
]);
break;
}
try {
$this->syncOne($sp);
$this->syncGroup($group);
$consecutiveTransient = 0;
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
continue;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window");
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
continue;
} catch (SupplierAuthException $e) {
@@ -107,7 +160,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
throw $e;
} catch (SupplierTransientException $e) {
$consecutiveTransient++;
$this->logSyncFailure($sp, $e);
$this->logGroupFailure($group, $e);
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
@@ -120,7 +173,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
} catch (SupplierClientException $e) {
$this->logSyncFailure($sp, $e);
$this->logGroupFailure($group, $e);
report($e);
continue;
@@ -128,131 +181,239 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
private function syncOne(SupplierProject $sp): void
/**
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
*/
private function syncGroup(array $group): void
{
$fkColumn = $this->fkColumnForPlatform($sp->platform);
$signalType = $group['signal_type'];
$identifier = $group['identifier'];
$platforms = $group['platforms'];
/** @var EloquentCollection<int, Project> $liderraProjects */
$liderraProjects = Project::on(self::DB_CONNECTION)
->where($fkColumn, $sp->id)
->where('is_active', true)
/** @var list<Project> $groupProjects */
$groupProjects = $group['projects'];
// Eligible-today: workday-mask for tomorrow
$targetDate = Carbon::tomorrow('Europe/Moscow');
$targetWeekday = $targetDate->isoWeekday();
/** @var list<Project> $eligible */
$eligible = array_values(array_filter(
$groupProjects,
fn (Project $p) => ($p->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
));
if ($eligible === []) {
return;
}
// Compute order and union workdays
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
$workdaysUnion = [];
foreach ($eligible as $p) {
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
$workdaysUnion[$d] = $d;
}
}
sort($workdaysUnion);
$workdays = $workdaysUnion;
// Portal constraint: one identifier = one B1/B2/B3 group — pass all regions as a single list.
$allRegions = $group['merged_regions'];
sort($allRegions);
// count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
$tag = count($allRegions) === 1
? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
: 'РФ';
// Find existing supplier_projects for this group (no subject_code filter)
$existingSps = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $identifier)
->where('signal_type', $signalType)
->whereIn('platform', $platforms)
->get();
if ($liderraProjects->isEmpty()) {
return;
}
if ($existingSps->isEmpty()) {
// Create path: saveProjectMultiFlag → [platform => external_id]
$dto = new SupplierProjectDto(
platform: $platforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $platforms,
);
$adapted = $this->adaptProjectsForAllocator($liderraProjects);
$idMap = $this->client->saveProjectMultiFlag($dto);
$allocation = SupplierQuotaAllocator::allocate(
platform: $sp->platform,
signalType: $sp->signal_type,
uniqueKey: $sp->unique_key,
activeLiderraProjects: $adapted,
targetDate: Carbon::tomorrow('Europe/Moscow'),
);
// Upsert supplier_projects rows (one per platform)
foreach ($platforms as $platform) {
$externalId = $idMap[$platform] ?? null;
if ($externalId === null) {
continue;
}
if ($allocation === null) {
return;
}
$sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $order,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
$current = SupplierProjectDto::fromModel($sp);
if ($allocation->equals($current)) {
return;
}
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'create',
'http_status' => 200,
'created_at' => now(),
]);
$isCreate = $sp->supplier_external_id === null;
// NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за
// границы транзакционного контекста, атомарности всё равно нет. Два DB-write
// (supplier_project update + supplier_sync_log insert) на одной connection
// выполняются последовательно; ошибка между ними — recoverable through retry
// на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()).
// Context-project для project_id в очереди яруса 3 при эскалации.
$contextProject = $liderraProjects->first();
if ($isCreate) {
$externalId = $this->channel instanceof FailoverProjectChannel
? $this->channel->createProjectForLiderra($contextProject, $allocation)
: $this->channel->createProject($allocation);
$sp->forceFill([
'supplier_external_id' => (string) $externalId,
'current_limit' => $allocation->limit,
'current_workdays' => $allocation->workdays,
'current_regions' => $allocation->regions,
'sync_status' => 'ok',
'last_synced_at' => now(),
])->save();
} else {
if ($this->channel instanceof FailoverProjectChannel) {
$this->channel->updateProjectForLiderra($contextProject, (int) $sp->supplier_external_id, $allocation);
} else {
$this->channel->updateProject((int) $sp->supplier_external_id, $allocation);
$existingSps->push($sp);
}
} else {
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
// (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется.
$existingPlatforms = $existingSps->pluck('platform')->all();
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
if ($missingPlatforms !== []) {
$missingDto = new SupplierProjectDto(
platform: $missingPlatforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $missingPlatforms,
);
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
foreach ($missingPlatforms as $platform) {
$externalId = $missingIdMap[$platform] ?? null;
if ($externalId === null) {
continue;
}
$sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => $order,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'create',
'http_status' => 200,
'created_at' => now(),
]);
$existingSps->push($sp);
}
}
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
foreach ($existingSps as $sp) {
if ($sp->supplier_external_id === null) {
continue;
}
$perPlatformDto = new SupplierProjectDto(
platform: $sp->platform,
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: [$sp->platform],
);
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
$sp->forceFill([
'current_limit' => $order,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
'last_synced_at' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
}
$sp->forceFill([
'current_limit' => $allocation->limit,
'current_workdays' => $allocation->workdays,
'current_regions' => $allocation->regions,
'sync_status' => 'ok',
'last_synced_at' => now(),
])->save();
}
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => $isCreate ? 'create' : 'update',
'http_status' => 200,
'created_at' => now(),
]);
// Pivot: for each contributing Лидерра-project × each supplier_project → ON CONFLICT DO NOTHING
foreach ($groupProjects as $lp) {
foreach ($existingSps as $sp) {
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $lp->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
]);
}
}
}
private function logSyncFailure(SupplierProject $sp, Throwable $e): void
/**
* Log failure for a group (before any supplier_project is created/updated we don't have sp id,
* so we look up existing or skip best-effort audit).
*
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
*/
private function logGroupFailure(array $group, Throwable $e): void
{
$httpStatus = null;
if ($e instanceof SupplierException) {
$httpStatus = $e->httpStatus;
}
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
'http_status' => $httpStatus,
'error_message' => substr($e->getMessage(), 0, 1000),
'created_at' => now(),
]);
// Find any existing sp row for the group to link log entry (no subject_code filter)
$sp = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $group['identifier'])
->where('signal_type', $group['signal_type'])
->first();
if ($sp !== null) {
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
'http_status' => $httpStatus,
'error_message' => substr($e->getMessage(), 0, 1000),
'created_at' => now(),
]);
}
}
/**
* Адаптер Eloquent Project stdClass с полями daily_limit/workdays/regions,
* которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent).
*
* Маппинг:
* daily_limit daily_limit_target
* workdays биты delivery_days_mask (bit 0=Пн, , bit 6=Вс) ISO 1..7
* regions projects.regions INT[] (subject codes 1..89) direct copy
*
* @param EloquentCollection<int, Project> $projects
* @return Collection<int, stdClass>
*/
private function adaptProjectsForAllocator(EloquentCollection $projects): Collection
{
return $projects->map(function (Project $p): stdClass {
$obj = new stdClass;
$obj->daily_limit = (int) $p->daily_limit_target;
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
// Empty array = "вся РФ" (паритет с supplier API semantics).
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
$obj->regions = array_values((array) $p->regions);
return $obj;
})->values();
}
/**
* Bitmask ordered list 1..maxBits для bits, выставленных в 1.
* Bitmask ordered list 1..maxBits.
*
* @return array<int, int>
*/
@@ -267,14 +428,4 @@ class SyncSupplierProjectsJob implements ShouldQueue
return $out;
}
private function fkColumnForPlatform(string $platform): string
{
return match ($platform) {
'B1' => 'supplier_b1_project_id',
'B2' => 'supplier_b2_project_id',
'B3' => 'supplier_b3_project_id',
default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"),
};
}
}
+240 -68
View File
@@ -11,32 +11,45 @@ use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierProjectGrouping;
use App\Support\RussianRegions;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Синхронизирует Лидерра-проект с supplier_projects на B1/B2/B3
* в зависимости от signal_type.
* в зависимости от signal_type и текущего SupplierExportMode.
*
* Семантика:
* site / call B1 + B2 + B3
* sms с keyword B2 + B3
* sms без keyword B3
* Режимы:
* online для каждой (subject × platform-set) группы проекта:
* saveProjectMultiFlag с полными параметрами (limit, regions, tag)
* upsert supplier_projects + pivot project_supplier_links.
* batch «каркас»: создаёт supplier_projects с limit=0, без регионов
* (старый путь); ночной SyncSupplierProjectsJob дольёт полные параметры.
*
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
*
* Канал миграции SupplierProjectChannel (резолвится в FailoverProjectChannel:
* ярус 1 AJAX ярус 2 browser-form ярус 3 manual queue). При эскалации на
* ярус 3 / переносе по окну портала platform пропускается (FK остаётся NULL,
* ночной SyncSupplierProjectsJob подберёт после ручного вмешательства).
* Канал миграции:
* batch mode SupplierProjectChannel (FailoverProjectChannel: ярус 1 AJAX
* ярус 2 browser-form ярус 3 manual queue) для createProject.
* online mode multi-flag save идёт напрямую через SupplierPortalClient
* (tier-1 AJAX only multi-flag нет в tier-2 form по архитектуре
* портала). При любом transient/auth fail log warning + skip
* subject; Laravel retry (tries=3 backoff [15s,60s,300s]) ночной
* SyncSupplierProjectsJob подберёт с полным failover каналом.
* updateProject в online остаётся через $channel (полная схема failover).
* При эскалации на ярус 3 / переносе по окну портала platform/subject пропускается
* (FK/pivot остаётся пустым; ночной SyncSupplierProjectsJob восстанавливает).
*
* Retry: 3 попытки с backoff [15s, 60s, 300s].
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §5
* Plan: docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 6
*/
class SyncSupplierProjectJob implements ShouldQueue
{
@@ -59,13 +72,203 @@ class SyncSupplierProjectJob implements ShouldQueue
return;
}
$platforms = $this->resolvePlatforms($project);
if (SupplierExportMode::isOnline()) {
$this->handleOnline($project, $channel);
} else {
$this->handleBatch($project, $channel);
}
}
// -------------------------------------------------------------------------
// Online mode: per-subject full-param sync
// -------------------------------------------------------------------------
private function handleOnline(Project $project, SupplierProjectChannel $channel): void
{
$client = app(SupplierPortalClient::class);
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
if ($platforms === []) {
return;
}
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
// Pass all project regions as a single group — no per-subject split.
$allRegions = array_map('intval', (array) ($project->regions ?? []));
// count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ'
$tag = count($allRegions) === 1
? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0])
: 'РФ';
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
$existingSps = SupplierProject::query()
->where('unique_key', $identifier)
->where('signal_type', (string) $project->signal_type)
->whereIn('platform', $platforms)
->get();
if ($existingSps->isEmpty()) {
// Create path: saveProjectMultiFlag → [platform => external_id]
$dto = new SupplierProjectDto(
platform: $platforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $platforms,
);
try {
$idMap = $client->saveProjectMultiFlag($dto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
return;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
return;
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
return;
}
foreach ($platforms as $platform) {
$externalId = $idMap[$platform] ?? null;
if ($externalId === null) {
continue;
}
$sp = SupplierProject::create([
'platform' => $platform,
'signal_type' => (string) $project->signal_type,
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => (int) $project->daily_limit_target,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
$existingSps->push($sp);
}
} else {
// Partial-set recovery: если предыдущий run создал не все platforms.
$existingPlatforms = $existingSps->pluck('platform')->all();
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
if ($missingPlatforms !== []) {
$missingDto = new SupplierProjectDto(
platform: $missingPlatforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $missingPlatforms,
);
try {
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
$missingIdMap = [];
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
$missingIdMap = [];
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
$missingIdMap = [];
}
foreach ($missingPlatforms as $platform) {
$externalId = $missingIdMap[$platform] ?? null;
if ($externalId === null) {
continue;
}
$sp = SupplierProject::create([
'platform' => $platform,
'signal_type' => (string) $project->signal_type,
'unique_key' => $identifier,
'subject_code' => null,
'supplier_external_id' => (string) $externalId,
'current_limit' => (int) $project->daily_limit_target,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
$existingSps->push($sp);
}
}
// Update existing supplier projects with current regions/limit.
foreach ($existingSps as $sp) {
if ($sp->supplier_external_id === null) {
continue;
}
$perPlatformDto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: [$sp->platform],
);
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
$sp->forceFill([
'current_limit' => (int) $project->daily_limit_target,
'current_workdays' => $workdays,
'current_regions' => $allRegions,
'sync_status' => 'ok',
'last_synced_at' => now(),
])->save();
}
}
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
foreach ($existingSps as $sp) {
DB::table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
]);
}
}
// -------------------------------------------------------------------------
// Batch mode: каркас (limit=0, no regions) — backward-compat
// -------------------------------------------------------------------------
private function handleBatch(Project $project, SupplierProjectChannel $channel): void
{
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
foreach ($platforms as $platform) {
$uniqueKey = $this->buildUniqueKey($project, $platform);
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
$column = 'supplier_'.strtolower($platform).'_project_id';
// Идемпотентность: local supplier_projects-запись для тройки уже есть?
// Idempotency: local supplier_projects-запись уже есть?
$existing = SupplierProject::query()
->where('platform', $platform)
->where('signal_type', $project->signal_type)
@@ -78,7 +281,16 @@ class SyncSupplierProjectJob implements ShouldQueue
continue;
}
$dto = $this->buildDto($project, $platform, $uniqueKey);
$dto = new SupplierProjectDto(
platform: $platform,
signalType: (string) $project->signal_type,
uniqueKey: $uniqueKey,
limit: 0,
workdays: $workdays,
regions: [],
regionsReverse: false,
status: 'active',
);
try {
$externalId = $channel instanceof FailoverProjectChannel
@@ -100,7 +312,7 @@ class SyncSupplierProjectJob implements ShouldQueue
'unique_key' => $uniqueKey,
'supplier_external_id' => (string) $externalId,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_workdays' => $workdays,
'current_regions' => null,
'sync_status' => 'ok',
]);
@@ -112,62 +324,22 @@ class SyncSupplierProjectJob implements ShouldQueue
}
/**
* Initial-create DTO: лимит 0 (квота приедет ночным SyncSupplierProjectsJob),
* полная неделя, без регионов.
*/
private function buildDto(Project $project, string $platform, string $uniqueKey): SupplierProjectDto
{
return new SupplierProjectDto(
platform: $platform,
signalType: (string) $project->signal_type,
uniqueKey: $uniqueKey,
limit: 0,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
}
/**
* Возвращает список uppercase platform-кодов для данного project.
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
* Bitmask ISO weekday list. bit 0 = Mon (ISO 1) bit 6 = Sun (ISO 7).
*
* @return array<int, string>
* Mirror of SyncSupplierProjectsJob::bitmaskToList(). Kept inline (not
* extracted to a shared helper) to keep this fix surgical.
*
* @return list<int>
*/
private function resolvePlatforms(Project $project): array
private function workdaysFromMask(int $mask): array
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return ['B1', 'B2', 'B3'];
$out = [];
for ($i = 0; $i < 7; $i++) {
if (($mask & (1 << $i)) !== 0) {
$out[] = $i + 1;
}
}
if ($project->signal_type === 'sms') {
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
}
return [];
}
/**
* Строит unique_key для пары (project, platform):
* site/call signal_identifier (домен / телефон)
* sms B2 sender + '+' + keyword
* sms B3 sender
*/
private function buildUniqueKey(Project $project, string $platform): string
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return (string) $project->signal_identifier;
}
// sms
$sender = (string) ($project->sms_senders[0] ?? '');
if ($platform === 'B2') {
return $sender.'+'.($project->sms_keyword ?? '');
}
// B3
return $sender;
return $out;
}
}
+2
View File
@@ -54,6 +54,7 @@ class Deal extends Model
'utm_campaign',
'utm_content',
'region_code',
'subject_code',
'city',
'time_in_form_seconds',
'lead_score',
@@ -72,6 +73,7 @@ class Deal extends Model
'duplicate_of_id' => 'integer',
'escalated_count' => 'integer',
'time_in_form_seconds' => 'integer',
'subject_code' => 'integer',
'lead_score' => 'decimal:2',
'phones' => 'array',
'is_test' => 'boolean',
+10
View File
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
/**
@@ -115,6 +116,15 @@ class Project extends Model
return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id');
}
/**
* @return BelongsToMany<SupplierProject, $this>
*/
public function supplierProjects(): BelongsToMany
{
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
->withPivot(['platform', 'subject_code']);
}
/**
* Активные проекты, у которых сегодняшний день включён в delivery_days_mask.
*
@@ -7,11 +7,24 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Очередь яруса 3 резерва канала миграции проектов.
*
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
*
* @property int $id
* @property int $project_id
* @property string $platform
* @property string $operation
* @property string|null $external_id
* @property array<string, mixed> $payload_snapshot
* @property string $failure_reason
* @property string $status
* @property int|null $resolved_by_user_id
* @property Carbon|null $created_at
* @property Carbon|null $resolved_at
*/
class SupplierManualSyncQueue extends Model
{
+12
View File
@@ -8,6 +8,7 @@ use Database\Factories\SupplierProjectFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* Supplier-уровневый агрегат проекта у поставщика crm.bp-gr.ru.
@@ -40,6 +41,7 @@ class SupplierProject extends Model
'sync_status',
'last_synced_at',
'inactive_since',
'subject_code',
];
protected function casts(): array
@@ -50,6 +52,7 @@ class SupplierProject extends Model
'current_limit' => 'integer',
'last_synced_at' => 'datetime',
'inactive_since' => 'datetime',
'subject_code' => 'integer',
];
}
@@ -81,6 +84,15 @@ class SupplierProject extends Model
return $query->where('signal_type', $signalType)->where('unique_key', $uniqueKey);
}
/**
* @return BelongsToMany<Project, $this>
*/
public function projects(): BelongsToMany
{
return $this->belongsToMany(Project::class, 'project_supplier_links')
->withPivot(['platform', 'subject_code']);
}
protected static function newFactory(): SupplierProjectFactory
{
return SupplierProjectFactory::new();
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Collection;
use Random\Randomizer;
/**
* Отбор получателей входящего лида: CAP случайных из eligible (sharing cap).
*
* cap=3 защита владельца номера-донора (лид продаётся максимум 3 раза).
* Eligible уже отфильтрован LeadRouter (есть остаток лимита) отбор лимит не
* превышает. Рандом через инъектируемый \Random\Randomizer (тесты сидируют
* Mt19937 для детерминизма; прод CSPRNG по умолчанию).
*
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.6.
*/
final class LeadDistributor
{
public const CAP = 3;
public function __construct(private readonly Randomizer $randomizer = new Randomizer) {}
/**
* @template T
*
* @param Collection<int, T> $eligible
* @return Collection<int, T>
*/
public function selectRecipients(Collection $eligible): Collection
{
$items = $eligible->values()->all();
if (count($items) <= self::CAP) {
return collect($items);
}
$keys = $this->randomizer->pickArrayKeys($items, self::CAP);
return collect($keys)->map(fn (int $k) => $items[$k])->values();
}
}
+20 -51
View File
@@ -8,70 +8,45 @@ use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use InvalidArgumentException;
/**
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
*
* Алгоритм:
* 1. SELECT projects WHERE supplier_b{1,2,3}_project_id = $supplier->id (по platform).
* 2. Фильтр: is_active=true.
* 3. Workdays: (delivery_days_mask & today_bit) <> 0, today_bit = 1 << (ISO_DOW - 1).
* 4. delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
* 5. tenants.balance_leads > 0 OR tenants.balance_rub > 0 (через WHERE EXISTS;
* Plan 4 Task 4: dual-balance rub-only tenant ДОЛЖЕН пройти, LedgerService
* сам резолвит prepaid/rub и кидает InsufficientBalanceException, если оба = 0).
* 6. Region match через PhonePrefixService::phoneMatchesRegions (в PHP, не в SQL
* district-bit резолвится по 3/4-значному коду в PHP-словаре).
* 7. Сортировка: created_at ASC, id ASC (детерминированно spec §6 step 4).
* Eligibility структурно через pivot project_supplier_links: проект eligible,
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
*
* Plan 3 Task 3: запрос идёт через connection `pgsql_supplier` (BYPASSRLS-роль
* crm_supplier_worker). Это закрывает WARN #2 — в sharing-flow tenant ещё не
* определён, SELECT обходит RLS-фильтрацию и видит проекты ВСЕХ tenant'ов
* параллельно. WHERE-фильтры (is_active, FK на supplier_project, workdays, лимиты,
* balance) сохраняются как defense-in-depth.
* Регион сопоставляется самим supplier_project (тег = субъект) phone-prefix
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
* гарантирован тем, через какой supplier_project пришёл лид.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 +
* docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) в
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
*
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
*/
class LeadRouter
{
public function __construct(
private readonly PhonePrefixService $phonePrefix,
) {}
/**
* @return Collection<int, Project>
*/
public function matchEligibleProjects(SupplierProject $supplierProject, string $phone): Collection
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
{
$fkColumn = match ($supplierProject->platform) {
'B1' => 'supplier_b1_project_id',
'B2' => 'supplier_b2_project_id',
'B3' => 'supplier_b3_project_id',
// Unreachable per CHECK chk_supplier_projects_platform; defensive for static analysis.
default => throw new InvalidArgumentException(
"Unknown supplier platform: {$supplierProject->platform}"
),
};
// МСК-aligned ISO day-of-week: Plan 2 Task 9 reset cron also runs at 00:00 МСК,
// so workday-mask check must use same timezone to avoid off-by-one near midnight.
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
/** @var Collection<int, Project> $candidates */
$candidates = Project::on('pgsql_supplier')
->where($fkColumn, $supplierProject->id)
->whereExists(function ($q) use ($supplierProject): void {
$q->selectRaw('1')
->from('project_supplier_links')
->whereColumn('project_supplier_links.project_id', 'projects.id')
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
})
->where('is_active', true)
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
->whereRaw(
'delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)'
)
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
->whereExists(function ($q): void {
// Plan 4 Task 4: dual-balance — допускаем rub-only tenant'ов.
// LedgerService::chargeForDelivery сам выбирает prepaid (balance_leads--)
// или rub (balance_rub -= tier_price) и кидает InsufficientBalanceException,
// если ОБА = 0. До Plan 4 фильтр был строгий balance_leads > 0 (prepaid only).
$q->selectRaw('1')
->from('tenants')
->whereColumn('tenants.id', 'projects.tenant_id')
@@ -84,12 +59,6 @@ class LeadRouter
->orderBy('id')
->get();
return $candidates->filter(
fn (Project $p): bool => $this->phonePrefix->phoneMatchesRegions(
$phone,
(int) $p->region_mask,
(string) $p->region_mode,
)
)->values();
return $candidates->values();
}
}
+6 -2
View File
@@ -32,10 +32,14 @@ class ProjectService
], 422));
}
// Resync на смену любого источник-несущего поля — поставщику нужно знать актуальный домен/телефон/sms.
// Resync на смену источник-несущих полей, регионов, лимита и дней недели —
// поставщик должен видеть актуальные параметры сразу, не дожидаясь ночного батча.
$needsResync = array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data)
|| array_key_exists('signal_identifier', $data);
|| array_key_exists('signal_identifier', $data)
|| array_key_exists('regions', $data)
|| array_key_exists('daily_limit_target', $data)
|| array_key_exists('delivery_days_mask', $data);
$project->update($data);
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Support\RussianRegions;
/**
* Резолвит регион-тег поставщика (raw_payload['tag'] = имя субъекта или «РФ»)
* в код субъекта 1..89. «РФ»/пусто/неизвестно null (пул «Вся РФ»/неизвестно).
*
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.4.
*/
final class RegionTagResolver
{
public function resolve(string $tag): ?int
{
$tag = trim($tag);
if ($tag === '' || $tag === 'РФ') {
return null;
}
return RussianRegions::nameToCode()[$tag] ?? null;
}
}
@@ -33,6 +33,9 @@ final readonly class SupplierProjectDto
array $regions,
public bool $regionsReverse, // false = include (default), true = exclude
public string $status, // active / paused
public string $tag = '_lidpotok',
/** @var array<int, string> */
public array $platforms = [],
) {
// Canonical order for deterministic equals() vs PG jsonb non-deterministic order.
// sort() reorders in-place AND re-indexes keys 0..N-1 (PHP guarantees list-semantics).
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use Illuminate\Support\Facades\DB;
/**
* Глобальный режим экспорта проектов поставщику (system_settings).
* 'online' sync сразу при create/edit с полными параметрами;
* 'batch' каркас сразу + полные параметры ночным SyncSupplierProjectsJob (18:00).
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
*/
final class SupplierExportMode
{
public const ONLINE = 'online';
public const BATCH = 'batch';
public static function current(): string
{
$value = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');
return $value === self::ONLINE ? self::ONLINE : self::BATCH;
}
public static function isOnline(): bool
{
return self::current() === self::ONLINE;
}
}
@@ -94,6 +94,40 @@ class SupplierPortalClient
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
}
/**
* R5: один save с флагами всех dto->platforms портал создаёт N rt-проектов,
* портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего
* дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
*
* @return array<string, int> [platform => external_id]
*/
public function saveProjectMultiFlag(SupplierProjectDto $dto): array
{
$response = $this->request(
'POST', '/admin/visit/rt-project-save',
$this->toPayload($dto, externalId: 0), asJson: true,
);
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
$srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
$out = [];
foreach ($this->listProjects() as $p) {
// Real portal returns name='B1_<identifier>' and identifier in 'content'.
// Test mocks omit 'content' and put identifier directly in 'name' — fall back to 'name'
// when 'content' is absent so both shapes work.
$identifier = $p['content'] ?? $p['name'] ?? null;
if ($identifier !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
continue;
}
$platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
$out[$platform] = (int) $p['id'];
}
}
return $out;
}
public function deleteProject(int $externalId): void
{
$response = $this->request(
@@ -320,9 +354,43 @@ class SupplierPortalClient
);
}
// Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
// сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
// refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
if ($this->isHtmlLoginPage($response)) {
if ($isRetry) {
throw new SupplierAuthException(
"Portal returned login page after refresh on {$path}",
httpStatus: $response->status(),
responseBody: $response->body(),
);
}
try {
dispatch_sync(app(RefreshSupplierSessionJob::class));
} catch (\Throwable $e) {
throw new SupplierAuthException(
"Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
httpStatus: $response->status(),
previous: $e,
);
}
return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
}
return $response;
}
private function isHtmlLoginPage(Response $response): bool
{
$contentType = $response->header('Content-Type');
if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
return false;
}
return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
}
/**
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
*/
@@ -385,16 +453,17 @@ class SupplierPortalClient
default => $dto->signalType,
};
$srcrt = $dto->platform === 'B1';
$srcbl = $dto->platform === 'B2';
$srcmt = $dto->platform === 'B3';
$platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];
$srcrt = in_array('B1', $platforms, true);
$srcbl = in_array('B2', $platforms, true);
$srcmt = in_array('B3', $platforms, true);
// workdays: int → string (portal: ["1","2",...,"7"]).
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
return [
'id' => $externalId,
'tag' => '_lidpotok',
'tag' => $dto->tag,
'name' => $dto->uniqueKey,
'type' => $type,
'content' => $dto->uniqueKey,
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use App\Models\Project;
/**
* DRY-хелперы для группировки Лидерра-проектов по (subject × platform-set).
*
* Используется в:
* - SyncSupplierProjectJob (онлайн-режим, один проект)
* - SyncSupplierProjectsJob (ночной батч, все проекты)
*
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
* Plan: docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 6
*/
final class SupplierProjectGrouping
{
/**
* Строит unique_key для пары (project, platform):
* site/call signal_identifier (домен / телефон)
* sms B2 sender + '+' + keyword
* sms B3 sender
*
* Для ночного батч-джоба используйте buildUniqueKeyNoplatform() он
* выбирает B2-ключ автоматически при наличии keyword.
*/
public static function buildUniqueKey(Project $project, string $platform): string
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return (string) $project->signal_identifier;
}
// sms
$sender = (string) ($project->sms_senders[0] ?? '');
if ($platform === 'B2') {
return $sender.'+'.($project->sms_keyword ?? '');
}
// B3
return $sender;
}
/**
* Unique identifier key без привязки к конкретной платформе
* (для группировки в ночном батч-джобе):
* site/call signal_identifier
* sms+keyword sender+keyword (B2 ключ)
* sms без keyword sender (B3 ключ)
*/
public static function buildUniqueKeyAgnostic(Project $project): string
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return (string) $project->signal_identifier;
}
$sender = (string) ($project->sms_senders[0] ?? '');
if ($project->sms_keyword !== null && $project->sms_keyword !== '') {
return $sender.'+'.$project->sms_keyword;
}
return $sender;
}
/**
* Возвращает список uppercase platform-кодов для данного project.
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
*
* @return list<string>
*/
public static function resolvePlatforms(Project $project): array
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return ['B1', 'B2', 'B3'];
}
if ($project->signal_type === 'sms') {
return ($project->sms_keyword !== null && $project->sms_keyword !== '')
? ['B2', 'B3']
: ['B3'];
}
return [];
}
/**
* Returns subjects (region codes 1..89) for a project.
* Empty regions [null] (one group, "Вся РФ" pool).
*
* @return list<int|null>
*/
public static function subjectsOf(Project $project): array
{
$regions = array_values((array) $project->regions);
// @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty
if (count($regions) === 0) {
return [null];
}
return array_map(fn ($r) => (int) $r, $regions);
}
}
@@ -9,26 +9,24 @@ use Carbon\Carbon;
use Illuminate\Support\Collection;
/**
* Pure function: распределение квоты daily_limit между platform B1/B2/B3.
* Pure function: формула заказа у поставщика на (источник × субъект).
*
* Используется SyncSupplierProjectsJob для агрегирования daily_limit_target
* всех активных Лидерра-проектов на одного supplier_project и распределения
* суммарной квоты между B1/B2/B3 платформами.
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён портал
* делит лимит сам (R6). Один лимит на группу eligible-клиентов:
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
*
* Distribution-формулы:
* site/call:
* B1 = ceil(total/3)
* B2 = ceil((total - B1) / 2)
* B3 = total - B1 - B2
* sms-with-keyword (B1 не поддерживает СМС):
* B1 = 0
* B2 = ceil(total/2)
* B3 = floor(total/2)
* ceil(Σ/3) ёмкость шаринга (лид продаётся ≤3 раз).
* наиб крупнейший клиент должен иметь шанс добрать.
*
* `allocate()` оставлен с прежней сигнатурой для временной совместимости
* c SyncSupplierProjectsJob внутри использует computeOrder, возвращает
* DTO с одинаковым limit на любую platform/signalType.
*
* Workdays и regions союзы (deduplicated, sorted) активных Лидерра-проектов,
* eligible на targetDate (фильтр по weekday в Europe/Moscow).
*
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
*/
final class SupplierQuotaAllocator
{
@@ -56,7 +54,9 @@ final class SupplierQuotaAllocator
$workdaysUnion = self::unionInts($eligibleProjects->pluck('workdays'));
$regionsUnion = self::unionInts($eligibleProjects->pluck('regions'));
$platformLimit = self::distributeForPlatform($signalType, $platform, $totalQuota);
$platformLimit = self::computeOrder(
$eligibleProjects->pluck('daily_limit')->map(fn ($v) => (int) $v)->all()
);
return new SupplierProjectDto(
platform: $platform,
@@ -70,28 +70,26 @@ final class SupplierQuotaAllocator
);
}
private static function distributeForPlatform(string $signalType, string $platform, int $total): int
/**
* Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
*
* ceil(Σ/3) ёмкость шаринга (лид продаётся ≤3 раз).
* наиб крупнейший клиент должен иметь шанс добрать.
*
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 наш split убран).
*
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
*/
public static function computeOrder(array $dailyLimits): int
{
if ($signalType === 'sms') {
if ($platform === 'B1') {
return 0;
}
return $platform === 'B2'
? (int) ceil($total / 2)
: (int) floor($total / 2);
if ($dailyLimits === []) {
return 0;
}
$b1 = (int) ceil($total / 3);
$b2 = (int) ceil(($total - $b1) / 2);
$b3 = $total - $b1 - $b2;
$sum = array_sum($dailyLimits);
$max = max($dailyLimits);
return match ($platform) {
'B1' => $b1,
'B2' => $b2,
'B3' => $b3,
default => 0,
};
return max($max, (int) ceil($sum / 3));
}
/**
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Канонический справочник субъектов РФ (1..89) PHP-зеркало
* resources/js/constants/regions.ts (конституционный порядок, ст. 65).
* Sentinel 0 «Вся РФ» не входит (= NULL subject_code / пустой regions).
*
* ВАЖНО: при правке regions.ts синхронно править этот файл (тест RegionTagResolverTest
* «mirrors regions.ts exactly 89» ловит расхождение по count, но не по именам
* сверять имена вручную при изменениях).
*/
final class RussianRegions
{
/** @var array<int, string> code(1..89) => официальное имя субъекта */
public const CODE_TO_NAME = [
// 24 республики
1 => 'Республика Адыгея',
2 => 'Республика Алтай',
3 => 'Республика Башкортостан',
4 => 'Республика Бурятия',
5 => 'Республика Дагестан',
6 => 'Донецкая Народная Республика',
7 => 'Республика Ингушетия',
8 => 'Кабардино-Балкарская Республика',
9 => 'Республика Калмыкия',
10 => 'Карачаево-Черкесская Республика',
11 => 'Республика Карелия',
12 => 'Республика Коми',
13 => 'Республика Крым',
14 => 'Луганская Народная Республика',
15 => 'Республика Марий Эл',
16 => 'Республика Мордовия',
17 => 'Республика Саха (Якутия)',
18 => 'Республика Северная Осетия — Алания',
19 => 'Республика Татарстан',
20 => 'Республика Тыва',
21 => 'Удмуртская Республика',
22 => 'Республика Хакасия',
23 => 'Чеченская Республика',
24 => 'Чувашская Республика',
// 9 краёв
25 => 'Алтайский край',
26 => 'Забайкальский край',
27 => 'Камчатский край',
28 => 'Краснодарский край',
29 => 'Красноярский край',
30 => 'Пермский край',
31 => 'Приморский край',
32 => 'Ставропольский край',
33 => 'Хабаровский край',
// 48 областей
34 => 'Амурская область',
35 => 'Архангельская область',
36 => 'Астраханская область',
37 => 'Белгородская область',
38 => 'Брянская область',
39 => 'Владимирская область',
40 => 'Волгоградская область',
41 => 'Вологодская область',
42 => 'Воронежская область',
43 => 'Запорожская область',
44 => 'Ивановская область',
45 => 'Иркутская область',
46 => 'Калининградская область',
47 => 'Калужская область',
48 => 'Кемеровская область',
49 => 'Кировская область',
50 => 'Костромская область',
51 => 'Курганская область',
52 => 'Курская область',
53 => 'Ленинградская область',
54 => 'Липецкая область',
55 => 'Магаданская область',
56 => 'Московская область',
57 => 'Мурманская область',
58 => 'Нижегородская область',
59 => 'Новгородская область',
60 => 'Новосибирская область',
61 => 'Омская область',
62 => 'Оренбургская область',
63 => 'Орловская область',
64 => 'Пензенская область',
65 => 'Псковская область',
66 => 'Ростовская область',
67 => 'Рязанская область',
68 => 'Самарская область',
69 => 'Саратовская область',
70 => 'Сахалинская область',
71 => 'Свердловская область',
72 => 'Смоленская область',
73 => 'Тамбовская область',
74 => 'Тверская область',
75 => 'Томская область',
76 => 'Тульская область',
77 => 'Тюменская область',
78 => 'Ульяновская область',
79 => 'Херсонская область',
80 => 'Челябинская область',
81 => 'Ярославская область',
// 3 города федерального значения
82 => 'Москва',
83 => 'Санкт-Петербург',
84 => 'Севастополь',
// 1 автономная область
85 => 'Еврейская автономная область',
// 4 автономных округа
86 => 'Ненецкий автономный округ',
87 => 'Ханты-Мансийский автономный округ — Югра',
88 => 'Чукотский автономный округ',
89 => 'Ямало-Ненецкий автономный округ',
];
/** @return array<string, int> name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
}
+8 -1
View File
@@ -18,6 +18,7 @@
"require-dev": {
"barryvdh/laravel-ide-helper": "*",
"deptrac/deptrac": "^4.6",
"driftingly/rector-laravel": "^2.3",
"fakerphp/faker": "^1.23",
"infection/infection": "^0.32.7",
"larastan/larastan": "*",
@@ -27,8 +28,10 @@
"laravel/pint": "^1.29",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"nunomaduro/phpinsights": "*",
"pestphp/pest": "^4.7",
"pestphp/pest-plugin-laravel": "^4.1",
"rector/rector": "^2.4",
"roave/security-advisories": "dev-latest"
},
"autoload": {
@@ -64,6 +67,9 @@
"pint:test": "@php vendor/bin/pint --test",
"test:parallel": "@php vendor/bin/pest --parallel --recreate-databases",
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
"rector": "@php vendor/bin/rector process --dry-run",
"rector:fix": "@php vendor/bin/rector process",
"insights": "@php artisan insights --no-interaction",
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
"audit-offline": "@composer audit --locked",
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
@@ -102,7 +108,8 @@
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"infection/extension-installer": true
"infection/extension-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"minimum-stability": "stable",
+2162 -1
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions;
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenFinalClasses;
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses;
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenPrivateMethods;
use NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits;
use NunoMaduro\PhpInsights\Domain\Insights\SyntaxCheck;
use NunoMaduro\PhpInsights\Domain\Metrics\Architecture\Classes;
use SlevomatCodingStandard\Sniffs\Commenting\UselessFunctionDocCommentSniff;
use SlevomatCodingStandard\Sniffs\Namespaces\AlphabeticallySortedUsesSniff;
use SlevomatCodingStandard\Sniffs\TypeHints\DeclareStrictTypesSniff;
use SlevomatCodingStandard\Sniffs\TypeHints\DisallowMixedTypeHintSniff;
use SlevomatCodingStandard\Sniffs\TypeHints\ParameterTypeHintSniff;
use SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff;
use SlevomatCodingStandard\Sniffs\TypeHints\ReturnTypeHintSniff;
return [
/*
|--------------------------------------------------------------------------
| Default Preset
|--------------------------------------------------------------------------
|
| This option controls the default preset that will be used by PHP Insights
| to make your code reliable, simple, and clean. However, you can always
| adjust the `Metrics` and `Insights` below in this configuration file.
|
| Supported: "default", "laravel", "symfony", "magento2", "drupal", "wordpress"
|
*/
'preset' => 'laravel',
/*
|--------------------------------------------------------------------------
| IDE
|--------------------------------------------------------------------------
|
| This options allow to add hyperlinks in your terminal to quickly open
| files in your favorite IDE while browsing your PhpInsights report.
|
| Supported: "textmate", "macvim", "emacs", "sublime", "phpstorm",
| "atom", "vscode".
|
| If you have another IDE that is not in this list but which provide an
| url-handler, you could fill this config with a pattern like this:
|
| myide://open?url=file://%f&line=%l
|
*/
'ide' => null,
/*
|--------------------------------------------------------------------------
| Configuration
|--------------------------------------------------------------------------
|
| Here you may adjust all the various `Insights` that will be used by PHP
| Insights. You can either add, remove or configure `Insights`. Keep in
| mind, that all added `Insights` must belong to a specific `Metric`.
|
*/
'exclude' => [
// 'path/to/directory-or-file'
],
'add' => [
Classes::class => [
ForbiddenFinalClasses::class,
],
],
'remove' => [
// SyntaxCheck спавнит дочерний `php -l` процесс — на native-Windows возвращает
// не-JSON и крашит PHP Insights (A1 backend-tooling, 20.05.2026). Избыточен:
// синтаксис ловят Pint / Larastan / сам PHP. Стиль — владелец Pint (BT4, ADR-013).
SyntaxCheck::class,
AlphabeticallySortedUsesSniff::class,
DeclareStrictTypesSniff::class,
DisallowMixedTypeHintSniff::class,
ForbiddenDefineFunctions::class,
ForbiddenNormalClasses::class,
ForbiddenTraits::class,
ParameterTypeHintSniff::class,
PropertyTypeHintSniff::class,
ReturnTypeHintSniff::class,
UselessFunctionDocCommentSniff::class,
],
'config' => [
ForbiddenPrivateMethods::class => [
'title' => 'The usage of private methods is not idiomatic in Laravel.',
],
],
/*
|--------------------------------------------------------------------------
| Requirements
|--------------------------------------------------------------------------
|
| Here you may define a level you want to reach per `Insights` category.
| When a score is lower than the minimum level defined, then an error
| code will be returned. This is optional and individually defined.
|
*/
'requirements' => [
// Anti-regression floors из baseline 20.05.2026 (Code 80 / Complexity 81 /
// Architecture 75). Чуть ниже текущих — гейт ловит деградацию, не текущий долг.
// Style НЕ гейтим — владелец стиля Pint (BT4, ADR-013). Security-check off —
// дублирует roave/security-advisories + composer audit.
'min-quality' => 78,
'min-complexity' => 79,
'min-architecture' => 73,
'disable-security-check' => true,
],
/*
|--------------------------------------------------------------------------
| Threads
|--------------------------------------------------------------------------
|
| Here you may adjust how many threads (core) PHPInsights can use to perform
| the analysis. This is optional, don't provide it and the tool will guess
| the max core number available. It accepts null value or integer > 0.
|
*/
'threads' => null,
/*
|--------------------------------------------------------------------------
| Timeout
|--------------------------------------------------------------------------
| Here you may adjust the timeout (in seconds) for PHPInsights to run before
| a ProcessTimedOutException is thrown.
| This accepts an int > 0. Default is 60 seconds, which is the default value
| of Symfony's setTimeout function.
|
*/
'timeout' => 60,
];
+6 -1
View File
@@ -7,6 +7,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Project>
@@ -20,7 +21,11 @@ class ProjectFactory extends Factory
{
return [
'tenant_id' => Tenant::factory(),
'name' => fake()->unique()->words(3, true),
// Квирк #77: fake()->unique() создаёт новый UniqueGenerator на каждый
// definition()-call → history между вызовами не сохраняется, uniqueness
// внутри batch не гарантирована (коллизия (tenant_id, name) UNIQUE в
// pest --parallel). Str::random(8) суффикс (62^8 ≈ 2e14) гасит коллизию.
'name' => fake()->words(3, true).' '.Str::random(8),
'type' => 'webhook',
'is_active' => true,
'daily_limit_target' => 10,
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Per-субъект supplier_projects (эпик переделки миграции проектов, v8.26).
*
* +subject_code SMALLINT NULL (1..89 субъект РФ; NULL = пул «Вся РФ»).
* Старый unique (platform, unique_key) (platform, unique_key, subject_code)
* NULLS NOT DISTINCT пул «Вся РФ» уникален per (platform, unique_key).
*
* Guard: migrate:fresh грузит schema.sql v8.26 (delta уже там) до миграций.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('supplier_projects', 'subject_code')) {
DB::statement('ALTER TABLE supplier_projects ADD COLUMN subject_code SMALLINT');
}
DB::statement(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_supplier_projects_subject_code'
) THEN
ALTER TABLE supplier_projects
ADD CONSTRAINT chk_supplier_projects_subject_code
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
END IF;
END $$;
SQL);
DB::statement('ALTER TABLE supplier_projects VALIDATE CONSTRAINT chk_supplier_projects_subject_code');
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_unique_key_unique');
DB::statement(
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_key_subject_unique '
.'ON supplier_projects (platform, unique_key, subject_code) NULLS NOT DISTINCT'
);
DB::statement(
'COMMENT ON COLUMN supplier_projects.subject_code IS '
."'Субъект РФ 1..89 (resources/js/constants/regions.ts). NULL = пул «Вся РФ». Эпик миграции проектов v8.26.'"
);
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_key_subject_unique');
DB::statement(
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_unique_key_unique '
.'ON supplier_projects (platform, unique_key)'
);
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT IF EXISTS chk_supplier_projects_subject_code');
if (Schema::hasColumn('supplier_projects', 'subject_code')) {
Schema::table('supplier_projects', fn ($t) => $t->dropColumn('subject_code'));
}
}
};
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* M:N pivot между projects (tenant) и supplier_projects (SaaS, shared) v8.26.
*
* Заменяет 3 FK-слота projects.supplier_b{1,2,3}_project_id (которые не вмещают
* per-субъект модель: N субъектов × до 3 платформ = до 3N связей).
* SaaS-level (без RLS, как supplier_projects): пишется sync-флоу, читается
* sharing-флоу через BYPASSRLS-роль crm_supplier_worker.
*/
return new class extends Migration
{
public function up(): void
{
$exists = DB::selectOne("SELECT to_regclass('public.project_supplier_links') AS r");
if ($exists !== null && $exists->r !== null) {
return;
}
DB::unprepared(<<<'SQL'
CREATE TABLE project_supplier_links (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
platform VARCHAR(4) NOT NULL,
subject_code SMALLINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1', 'B2', 'B3')),
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
);
CREATE INDEX idx_psl_supplier_project ON project_supplier_links (supplier_project_id);
CREATE INDEX idx_psl_project ON project_supplier_links (project_id);
SQL);
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS project_supplier_links');
}
};
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* deals.subject_code субъект РФ из тега поставщика (raw_payload['tag']) v8.26.
*
* Источник истины региона сделки = тег проекта у поставщика (надёжнее phone-prefix
* для мобильных). Отдельно от deals.region_code (ISO-3166, phone-derived).
* deals партиционирована ADD COLUMN наследуется партициями.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('deals', 'subject_code')) {
DB::statement('ALTER TABLE deals ADD COLUMN subject_code SMALLINT');
}
DB::statement(
'COMMENT ON COLUMN deals.subject_code IS '
."'Субъект РФ 1..89 из тега поставщика (raw_payload[tag]). NULL = «Вся РФ»/неизвестно. v8.26.'"
);
}
public function down(): void
{
if (Schema::hasColumn('deals', 'subject_code')) {
Schema::table('deals', fn ($t) => $t->dropColumn('subject_code'));
}
}
};
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Глобальный тумблер режима экспорта проектов поставщику (v8.26).
* 'batch' (default, прод-безопасно) | 'online'. Резолвится SupplierExportMode (План 3).
*/
return new class extends Migration
{
public function up(): void
{
$exists = DB::table('system_settings')->where('key', 'supplier_export_mode')->exists();
if ($exists) {
return;
}
DB::table('system_settings')->insert([
'key' => 'supplier_export_mode',
'value' => 'batch',
'type' => 'string',
'description' => 'Режим экспорта проектов поставщику: batch (ночной 18:00) | online (сразу при правке).',
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
}
};
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Бэкофилл pivot project_supplier_links из legacy supplier_b{1,2,3}_project_id (v8.26).
*
* Для каждого ненулевого слота строка pivot (subject_code=NULL: legacy-записи без
* субъекта). Идемпотентно ON CONFLICT DO NOTHING по uq_psl_project_supplier.
*/
return new class extends Migration
{
public function up(): void
{
foreach (['B1' => 'supplier_b1_project_id', 'B2' => 'supplier_b2_project_id', 'B3' => 'supplier_b3_project_id'] as $platform => $col) {
DB::statement(
'INSERT INTO project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at) '
."SELECT id, {$col}, ?, NULL, NOW() FROM projects WHERE {$col} IS NOT NULL "
.'ON CONFLICT (project_id, supplier_project_id) DO NOTHING',
[$platform]
);
}
}
public function down(): void
{
// Бэкофилл-данные не откатываем точечно (pivot живёт дальше); no-op.
}
};
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* deals.subject_code range CHECK 1..89 defensive parity с supplier_projects.subject_code (v8.26).
*
* Reviewer-finding (Plan 1 code-quality): supplier_projects.subject_code имеет CHECK 1..89,
* deals.subject_code только COMMENT. Malformed webhook tag silent garbage в deals
* downstream report-by-region undercounts. NOT VALID + VALIDATE (squawk-safe), idempotent.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_deals_subject_code'
) THEN
ALTER TABLE deals
ADD CONSTRAINT chk_deals_subject_code
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
END IF;
END $$;
SQL);
DB::statement('ALTER TABLE deals VALIDATE CONSTRAINT chk_deals_subject_code');
}
public function down(): void
{
DB::statement('ALTER TABLE deals DROP CONSTRAINT IF EXISTS chk_deals_subject_code');
}
};
+115 -61
View File
@@ -204,12 +204,6 @@ parameters:
count: 3
path: app/Jobs/ImportLeadsJob.php
-
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -252,6 +246,18 @@ parameters:
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
-
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: app/Services/Supplier/SupplierProjectGrouping.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -318,6 +324,18 @@ parameters:
count: 1
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -330,6 +348,60 @@ parameters:
count: 3
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierExportModeEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Admin/SupplierManualQueueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierManualQueueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierManualQueueTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -1497,7 +1569,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 12
count: 14
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
@@ -1746,6 +1818,36 @@ parameters:
count: 1
path: tests/Feature/Supplier/AutoPauseFlowTest.php
-
message: '#^Access to an undefined property App\\Services\\Supplier\\PlaywrightBridge\:\:\$lastArgs\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/Supplier/Channel/FormProjectChannelTest.php
-
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/CsvReconcileJobTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 2
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 2
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -1782,6 +1884,12 @@ parameters:
count: 1
path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1902,62 +2010,8 @@ parameters:
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 1, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 3
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 6
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{6, 7\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 30, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 4, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 3
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 7, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
identifier: argument.type
count: 3
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
-
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/CsvReconcileJobTest.php
@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>RT Project Form Fixture — Element UI + Vuetify dialog</title>
<style>
/* Minimal stubs so Playwright class-based locators work */
.el-form-item { margin-bottom: 12px; }
.el-form-item__label { display: inline-block; min-width: 140px; }
.el-form-item__content { display: inline-block; }
.el-input__inner { border: 1px solid #cccccc; padding: 4px 8px; }
.el-checkbox { cursor: pointer; margin-right: 8px; }
.el-checkbox__input.is-checked .el-checkbox__inner { background: #409eff; }
.el-checkbox__inner { display: inline-block; width: 14px; height: 14px; border: 1px solid #cccccc; }
.el-switch { cursor: pointer; }
.el-switch.is-checked .el-switch__core { background: #409eff; }
.el-switch__core { display: inline-block; width: 40px; height: 20px; border-radius: 10px; background: #cccccc; }
.el-select-dropdown { position: absolute; background: #ffffff; border: 1px solid #cccccc; z-index: 9999; min-width: 120px; }
.el-select-dropdown__item { padding: 6px 12px; cursor: pointer; }
.el-select-dropdown__item:hover { background: #f5f7fa; }
.el-button { padding: 6px 16px; cursor: pointer; border: 1px solid #cccccc; background: #ffffff; }
.el-input-number .el-input__inner { width: 80px; }
</style>
</head><body>
<!-- Vuetify dialog wrapper — required by manage-project.js locator ".v-dialog--active button:has-text(...)" -->
<div class="v-dialog v-dialog--active v-dialog--persistent" style="padding:16px;">
<form class="el-form el-form--label-left">
<!-- 1. Tag -->
<div class="el-form-item">
<label class="el-form-item__label" for="tag">Тег</label>
<div class="el-form-item__content">
<div class="el-input">
<input type="text" class="el-input__inner" id="tag-fixture">
</div>
</div>
</div>
<!-- 2. Источник данных (B1/B2/B3 checkboxes) — label for="srcrt" -->
<div class="el-form-item">
<label class="el-form-item__label" for="srcrt">Источник данных</label>
<div class="el-form-item__content" id="srcrt-container">
<label class="el-checkbox is-checked" data-platform="B1">
<span class="el-checkbox__input is-checked">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original" checked>
</span>
<span class="el-checkbox__label">B1</span>
</label>
<label class="el-checkbox is-checked" data-platform="B2">
<span class="el-checkbox__input is-checked">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original" checked>
</span>
<span class="el-checkbox__label">B2</span>
</label>
<label class="el-checkbox is-checked" data-platform="B3">
<span class="el-checkbox__input is-checked">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original" checked>
</span>
<span class="el-checkbox__label">B3</span>
</label>
</div>
</div>
<!-- 3. Name — label for="name" -->
<div class="el-form-item">
<label class="el-form-item__label" for="name">Название проекта</label>
<div class="el-form-item__content">
<div class="el-input">
<input type="text" class="el-input__inner" id="name-fixture">
</div>
</div>
</div>
<!-- 4. Type select — label for="type" -->
<div class="el-form-item">
<label class="el-form-item__label" for="type">Источники сбора</label>
<div class="el-form-item__content">
<div class="el-select" id="type-select-container">
<!-- readonly input that shows selected value; clicking it opens dropdown popup in body -->
<div class="el-input">
<input type="text" class="el-input__inner" id="type-select-input" readonly
value="Сайты" placeholder="Выберите" data-current-value="Сайты">
<span class="el-input__suffix"><span class="el-select__caret"></span></span>
</div>
</div>
</div>
</div>
<!-- 5. Slider «Период» — no label-for, no DTO field, leave default -->
<div class="el-form-item">
<label class="el-form-item__label">Период</label>
<div class="el-form-item__content">
<div class="el-slider" aria-valuemin="0" aria-valuemax="24" aria-valuetext="10-18">
<span style="font-size:12px;color:#999999">10-18 (default)</span>
</div>
</div>
</div>
<!-- 6. Switch «Включить» — no label-for; identified by .el-switch in form-item -->
<div class="el-form-item" id="switch-form-item">
<label class="el-form-item__label">Статус</label>
<div class="el-form-item__content">
<div class="el-switch" id="active-switch">
<input type="checkbox" class="el-switch__input" id="active-switch-input">
<span class="el-switch__core"></span>
<span>Включить</span>
</div>
</div>
</div>
<!-- 7. Regions — label for="regions", el-select multiple -->
<div class="el-form-item">
<label class="el-form-item__label" for="regions">Регион</label>
<div class="el-form-item__content">
<div class="el-select el-select--multiple">
<input type="text" class="el-input__inner" id="regions-input" placeholder="Выберите регионы">
</div>
</div>
</div>
<!-- 8. limit_off — no label-for, no DTO field -->
<div class="el-form-item">
<label class="el-form-item__label" for="limit_off">Разделять по проектам</label>
<div class="el-form-item__content">
<label class="el-checkbox">
<span class="el-checkbox__input">
<span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original">
</span>
<span class="el-checkbox__label">Да</span>
</label>
</div>
</div>
<!-- 9. Content (uniqueKey / domains) — label for="content", el-tabs -->
<div class="el-form-item">
<label class="el-form-item__label" for="content">Список сайтов</label>
<div class="el-form-item__content">
<div class="el-tabs">
<div class="el-tabs__header">
<div class="el-tabs__item is-active" data-tab="list">Список</div>
<div class="el-tabs__item" data-tab="file">Файл</div>
</div>
<div class="el-tabs__content">
<textarea class="el-textarea__inner" id="content-textarea" rows="4" style="width:100%"></textarea>
</div>
</div>
</div>
</div>
<!-- 10. Limit — label for="limit", el-input-number -->
<div class="el-form-item">
<label class="el-form-item__label" for="limit">Лимит в день</label>
<div class="el-form-item__content">
<div class="el-input-number">
<span class="el-input-number__decrease">-</span>
<div class="el-input">
<input type="text" class="el-input__inner" id="limit-input" value="10">
</div>
<span class="el-input-number__increase">+</span>
</div>
</div>
</div>
</form><!-- end .el-form -->
<!-- Save/Cancel buttons OUTSIDE form, INSIDE .v-dialog--active -->
<div style="margin-top:16px;">
<button type="button" class="el-button" id="save-btn">Сохранить</button>
<button type="button" class="el-button" id="cancel-btn">Отмена</button>
</div>
</div><!-- end .v-dialog--active -->
<script>
(function() {
'use strict';
// ---- Checkbox toggle behaviour ----
// Click on .el-checkbox toggles .is-checked on itself and .el-checkbox__input child
document.querySelectorAll('#srcrt-container .el-checkbox').forEach(function(cb) {
cb.addEventListener('click', function(e) {
e.preventDefault();
var isChecked = cb.classList.contains('is-checked');
cb.classList.toggle('is-checked', !isChecked);
var cbInput = cb.querySelector('.el-checkbox__input');
if (cbInput) cbInput.classList.toggle('is-checked', !isChecked);
var rawInput = cb.querySelector('input.el-checkbox__original');
if (rawInput) rawInput.checked = !isChecked;
});
});
// ---- Switch toggle behaviour ----
var switchEl = document.getElementById('active-switch');
if (switchEl) {
switchEl.addEventListener('click', function(e) {
e.preventDefault();
var isChecked = switchEl.classList.contains('is-checked');
switchEl.classList.toggle('is-checked', !isChecked);
var inp = document.getElementById('active-switch-input');
if (inp) inp.checked = !isChecked;
});
}
// ---- Type select popup ----
// When input#type-select-input is clicked, create a dropdown in body
var typeInput = document.getElementById('type-select-input');
var typeOptions = ['Сайты', 'Звонки', 'СМС', 'Ретро сайты', 'Ретро звонки'];
function removeDropdown() {
var existing = document.querySelector('body > .el-select-dropdown');
if (existing) existing.remove();
}
if (typeInput) {
typeInput.addEventListener('click', function(e) {
e.stopPropagation();
removeDropdown();
var dropdown = document.createElement('div');
dropdown.className = 'el-select-dropdown el-popper';
dropdown.style.position = 'absolute';
dropdown.style.left = '20px';
dropdown.style.top = '200px';
var ul = document.createElement('ul');
ul.className = 'el-scrollbar__view el-select-dropdown__list';
typeOptions.forEach(function(opt) {
var li = document.createElement('li');
li.className = 'el-select-dropdown__item';
li.textContent = opt;
li.addEventListener('click', function(e2) {
e2.stopPropagation();
typeInput.value = opt;
typeInput.setAttribute('data-current-value', opt);
removeDropdown();
});
ul.appendChild(li);
});
dropdown.appendChild(ul);
document.body.appendChild(dropdown);
});
}
// Close dropdown on outside click
document.addEventListener('click', function() {
removeDropdown();
});
// ---- Save button: POST to /admin/visit/rt-project-save on the same origin ----
// NOTE: NO fetch mock here — the HTTP server (manage-project.test.js) handles
// this route and returns {status:"OK",id:"99001"}. Playwright's waitForResponse
// intercepts real network requests, not mocked fetch.
document.getElementById('save-btn').addEventListener('click', function() {
var payload = {
tag: document.getElementById('tag-fixture') ? document.getElementById('tag-fixture').value : '',
name: document.getElementById('name-fixture') ? document.getElementById('name-fixture').value : '',
type: typeInput ? typeInput.getAttribute('data-current-value') : 'Сайты',
limit: document.getElementById('limit-input') ? document.getElementById('limit-input').value : '10',
};
fetch('/admin/visit/rt-project-save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
});
})();
</script>
</body></html>
+284 -49
View File
@@ -18,11 +18,35 @@
* 4 invalid input или другая ошибка
*
* Spec §4.3.
*
* KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19):
* - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период»
* часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней).
* Для точной настройки workdays используйте Tier-1 (AJAX).
* - regions: форма требует имена регионов, DTO несёт int[] id. Mapping idname не реализован.
* Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть
* настроены вручную или через Tier-1.
*/
const { chromium } = require('playwright');
const TIMEOUT_MS = 90_000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Возвращает локатор form-item по значению атрибута for= у label.
* Стратегия: .el-form-item:has(.el-form-item__label[for="<attrFor>"])
*/
function fieldByFor(page, attrFor) {
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
}
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
async function login(page, args) {
// skipLogin: args.url — статическая фикстура формы (тестовый режим),
// открываем её напрямую и не логинимся.
@@ -39,98 +63,301 @@ async function login(page, args) {
]);
}
// ---------------------------------------------------------------------------
// fillForm — Element UI label-for локаторы (recon 2026-05-19)
// ---------------------------------------------------------------------------
async function fillForm(page, dto) {
const activeChecked = await page.locator('input[name=active]').isChecked();
if (activeChecked !== !!dto.active) await page.locator('input[name=active]').click();
// NOTE: статус active/paused НЕ выставляется через форму. Единственный
// .el-switch на форме — это include/exclude регионов («Включить/Исключить»,
// recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом
// портала (active). dto.active игнорируется в Tier-2; switch не трогаем
// (regions skip — см. ниже). Verified live 2026-05-19.
if (dto.tag) await page.fill('input[name=tag]', dto.tag);
// --- 1. Tag ---
if (dto.tag !== undefined && dto.tag !== null) {
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag));
}
// --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes ---
// Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить.
const platformContainer = fieldByFor(page, 'srcrt');
for (const p of ['B1', 'B2', 'B3']) {
const wanted = (dto.platforms || []).includes(p);
const sel = `input[name="platform[]"][value="${p}"]`;
const checked = await page.locator(sel).isChecked();
if (checked !== wanted) await page.locator(sel).click();
// Identification — по `.el-checkbox__label` textContent (per recon-doc
// 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет
// `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`.
const cb = platformContainer.locator('.el-checkbox').filter({
has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }),
}).first();
const cbClass = await cb.getAttribute('class').catch(() => '');
const isChecked = (cbClass || '').includes('is-checked');
if (!!isChecked !== wanted) {
await cb.click();
}
}
await page.fill('input[name=name]', dto.name);
const signalLabel = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }[dto.signal_type] || 'Сайты';
await page.selectOption('select[name=signal_type]', { label: signalLabel });
if (dto.region_mode === 'exclude') {
await page.locator('input[name=region_mode][value=exclude]').click();
// --- 3. Name (label for="name") ---
// В реальном портале dto.name заполняется в поле «Название проекта»,
// а dto.uniqueKey (список сайтов/номеров) — в textarea «content».
// manage-project.js получает dto.name напрямую.
if (dto.name !== undefined) {
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name));
}
if (dto.domains && dto.domains.length) {
await page.fill('textarea[name=domains]', dto.domains.join('\n'));
// --- 4. Type select (label for="type") ---
// El-select readonly input. Клик открывает popup в body > .el-select-dropdown.
const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' };
const signalLabel = signalTypeMap[dto.signal_type];
if (!signalLabel) {
throw new Error(
`Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` +
'"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.',
);
}
// Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит
// content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу
// после type-select заполнять content, fill попадёт в detached textarea
// (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым
// `content` → портал «Введите домены». Verified live 2026-05-19.
const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner');
const currentType = (await typeInput.inputValue().catch(() => '')).trim();
if (currentType !== signalLabel) {
await typeInput.click();
// Dropdown рендерится снаружи формы в body — ждём его появления
const dropdownOption = page.locator('.el-select-dropdown__item', {
hasText: new RegExp(`^${signalLabel}$`),
});
await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
await dropdownOption.click();
// Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа.
await page.waitForTimeout(1000);
}
await page.fill('input[name=limit]', String(dto.limit));
// --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc ---
// DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP.
if (dto.regions && dto.regions.length > 0) {
process.stderr.write(
JSON.stringify({
warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' +
'Region filtering will not be applied. Configure regions manually or use Tier-1.',
regions_received: dto.regions,
}) + '\n',
);
}
for (let d = 1; d <= 7; d++) {
const wanted = (dto.workdays || [1, 2, 3, 4, 5, 6, 7]).includes(d);
const sel = `input[name="workdays[]"][value="${d}"]`;
const checked = await page.locator(sel).isChecked();
if (checked !== wanted) await page.locator(sel).click();
// --- 9. Content — список сайтов/номеров/отправителей (label for="content") ---
// Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка.
const contentLines = dto.domains && dto.domains.length
? dto.domains.join('\n')
: dto.uniqueKey
? String(dto.uniqueKey)
: null;
if (contentLines) {
const contentField = fieldByFor(page, 'content');
// Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна:
// клик по вкладке Element UI ремоунтит tab-pane → textarea детачится,
// и последующий .fill() гонится с ре-рендером (домены теряются →
// rt-project-save уходит с пустым `content` → портал «Введите домены»).
// Verified live 2026-05-19: re-click активной вкладки ломал save.
const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first();
if ((await listTab.count()) > 0) {
const tabClass = (await listTab.getAttribute('class')) || '';
if (!tabClass.includes('is-active')) {
await listTab.click();
await contentField.locator('textarea.el-textarea__inner')
.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
}
}
const contentTa = contentField.locator('textarea.el-textarea__inner');
await contentTa.fill(contentLines);
// Defensive: убедиться, что значение действительно осело в textarea
// (если поле детачнулось ре-рендером — fill уйдёт в пустоту).
const filledValue = await contentTa.inputValue();
if (filledValue.trim() === '') {
throw new Error(
'Content textarea empty after fill — likely tab/type re-render race; domains lost',
);
}
}
// --- 10. Limit (label for="limit") ---
if (dto.limit !== undefined) {
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
}
// NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит
// чекбоксы дней недели. dto.workdays игнорируется.
if (dto.workdays && dto.workdays.length !== 7) {
process.stderr.write(
JSON.stringify({
warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' +
'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.',
workdays_received: dto.workdays,
}) + '\n',
);
}
}
// ---------------------------------------------------------------------------
// createOp
// ---------------------------------------------------------------------------
async function createOp(page, args) {
await login(page, args);
if (!args.skipLogin) {
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
await page.click('button:has-text("Добавить проект")');
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
await page.goto(
args.url.replace(/\/$/, '') + '/admin/visit/rt',
{ waitUntil: 'load', timeout: TIMEOUT_MS },
);
// Кнопка «Добавить проект» — recon: label [title="Добавить проект"]
await page.locator('button:has-text("Добавить проект")').click();
// Ждём появления формы — label for="name" внутри .el-form
await page.locator('.el-form-item__label[for="name"]').waitFor({
state: 'visible',
timeout: TIMEOUT_MS,
});
}
await fillForm(page, args.dto);
const beforeRows = await page.locator('#projects-table tbody tr').count();
await page.click('#save-btn');
await page.waitForFunction(
(before) => document.querySelectorAll('#projects-table tbody tr').length > before,
beforeRows,
{ timeout: TIMEOUT_MS },
);
const newRow = page.locator('#projects-table tbody tr').last();
const externalId = await newRow.getAttribute('data-id');
// Кликаем «Сохранить» + перехватываем ответ rt-project-save
const [saveResponse] = await Promise.all([
page.waitForResponse(
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
{ timeout: TIMEOUT_MS },
),
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
]);
const body = await saveResponse.json();
if (body.status !== 'OK') {
// DIAG: дамп фактически отправленного тела — для расследования "Введите домены"
const sentBody = saveResponse.request().postData();
process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n');
throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`);
}
const externalId = String(body.id ?? '');
if (!externalId) {
throw new Error('Portal returned status=OK but empty id');
}
return { external_id: externalId };
}
// ---------------------------------------------------------------------------
// updateOp
// ---------------------------------------------------------------------------
async function updateOp(page, args) {
await login(page, args);
if (!args.skipLogin) {
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
await page.goto(
args.url.replace(/\/$/, '') + '/admin/visit/rt',
{ waitUntil: 'load', timeout: TIMEOUT_MS },
);
}
const row = page.locator(`#projects-table tbody tr[data-id="${args.externalId}"]`);
await row.locator('button.edit').click();
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
// Найти строку таблицы по externalId и кликнуть кнопку редактирования.
// Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению.
// Стратегия 1: строка с атрибутом data-id
const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`);
const rowCount = await rowLocator.count();
if (rowCount > 0) {
await rowLocator.first().locator('button').first().click();
} else {
// Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку
await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click();
}
// Дождаться формы
await page.locator('.el-form-item__label[for="name"]').waitFor({
state: 'visible',
timeout: TIMEOUT_MS,
});
await fillForm(page, args.dto);
await page.click('#save-btn');
await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });
// Перехватываем ответ rt-project-save при update (тот же endpoint)
const [saveResponse] = await Promise.all([
page.waitForResponse(
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
{ timeout: TIMEOUT_MS },
),
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
]);
const body = await saveResponse.json();
if (body.status !== 'OK') {
throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`);
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// listOp
// ---------------------------------------------------------------------------
async function listOp(page, args) {
await login(page, args);
if (!args.skipLogin) {
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
await page.goto(
args.url.replace(/\/$/, '') + '/admin/visit/rt',
{ waitUntil: 'load', timeout: TIMEOUT_MS },
);
}
const rows = await page.locator('#projects-table tbody tr').evaluateAll((nodes) =>
nodes.map((n) => ({
id: parseInt(n.dataset.id, 10),
name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent : null,
// Стратегия 1: Vuex state (если доступен)
const projects = await page.evaluate(() => {
try {
if (window.app && window.app.$store && window.app.$store.state) {
const st = window.app.$store.state;
const list = st.projects || st.rtProjects || st.visitProjects || null;
if (Array.isArray(list)) {
return list.map((p) => ({
id: parseInt(p.id, 10),
name: p.name || p.title || null,
platform: p.platform || null,
signal_type: p.type || p.signal_type || null,
unique_key: p.content || p.unique_key || null,
}));
}
}
} catch (_) { /* Vuex недоступен */ }
return null;
});
if (projects !== null) {
return { projects };
}
// Стратегия 2: DOM-скрейп таблицы
// Реальная таблица портала: строки tr с data-id или стандартные td
const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll(
(nodes) => nodes.map((n) => ({
id: parseInt(n.dataset.id || '0', 10),
name: n.querySelector('td:nth-child(2)')
? n.querySelector('td:nth-child(2)').textContent.trim()
: null,
})),
);
return { projects: rows };
if (rows.length > 0) {
return { projects: rows };
}
// Стратегия 3: фикстура / пустая страница — возвращаем пустой массив
return { projects: [] };
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
async function run(args) {
const browser = await chromium.launch({ headless: true });
try {
@@ -148,8 +375,14 @@ async function run(args) {
} catch (err) {
process.stderr.write(JSON.stringify({ error: err.message }));
if (err.message.includes('Timeout')) process.exit(3);
if (err.message.toLowerCase().includes('selector') || err.message.toLowerCase().includes('locator')) process.exit(2);
if (err.message.toLowerCase().includes('login') || err.message.toLowerCase().includes('auth')) process.exit(1);
if (
err.message.toLowerCase().includes('selector') ||
err.message.toLowerCase().includes('locator')
) process.exit(2);
if (
err.message.toLowerCase().includes('login') ||
err.message.toLowerCase().includes('auth')
) process.exit(1);
process.exit(4);
} finally {
await browser.close();
@@ -160,8 +393,10 @@ let input = '';
process.stdin.on('data', (c) => { input += c; });
process.stdin.on('end', () => {
let args;
try { args = JSON.parse(input); }
catch (e) { process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' })); process.exit(4); }
try { args = JSON.parse(input); } catch (e) {
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
process.exit(4);
}
if (!args.operation || !args.url) {
process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' }));
process.exit(4);
+115 -43
View File
@@ -1,65 +1,137 @@
/**
* Фикстурный тест manage-project.js против локального HTML, без живого портала.
* Фикстурный тест manage-project.js против локального HTTP-сервера с Element UI фикстурой.
*
* Runner: встроенный node:test (проект не использует @playwright/test
* в app/playwright только playwright core). Запуск: `node --test manage-project.test.js`.
* Почему HTTP, не file://: manage-project.js перехватывает ответ page.waitForResponse()
* с URL endsWith('/admin/visit/rt-project-save'). Браузер не шлёт network-запросы при
* file://-origin fetch из-за CORS/same-origin ограничений в Chromium.
*
* Runner: встроенный node:test (Node 18+). Запуск: `node --test manage-project.test.js`.
*/
const { test } = require('node:test');
const assert = require('node:assert');
const { execFile } = require('node:child_process');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
const FIXTURE_URL = 'file://' + path.resolve(__dirname, '../tests/fixtures/supplier-portal/rt-add-project-form.html');
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'rt-form-element-ui.html');
/** Запустить ephemeral HTTP-сервер, отдающий фикстуру и обрабатывающий mock-эндпоинты. */
function startFixtureServer() {
return new Promise((resolve) => {
const html = fs.readFileSync(FIXTURE_PATH, 'utf8');
const server = http.createServer((req, res) => {
// Mock rt-project-save — Playwright перехватывает реальный сетевой запрос
if (req.url && req.url.includes('rt-project-save') && req.method === 'POST') {
// Consume request body (important — don't hang connection)
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const payload = JSON.stringify({ status: 'OK', message: '', result: null, id: '99001' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(payload);
});
return;
}
// Default: serve fixture HTML
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
});
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
/** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */
function runScript(input) {
return new Promise((resolve, reject) => {
const child = execFile('node', [SCRIPT], { timeout: 60000 }, (err, stdout, stderr) => {
if (err && err.code !== undefined && typeof err.code !== 'number') {
return reject(err);
}
resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
});
const child = execFile(
'node',
[SCRIPT],
{ timeout: 90_000 },
(err, stdout, stderr) => {
if (err && err.killed) return reject(new Error('Process killed / timed out'));
// err.code — exit code; treat as expected (tests assert on code)
resolve({
code: err ? err.code : 0,
stdout: stdout.toString(),
stderr: stderr.toString(),
});
},
);
child.stdin.write(JSON.stringify(input));
child.stdin.end();
});
}
test('createProject fills form and returns row id', async () => {
const result = await runScript({
operation: 'create',
login: 'fixture-noop',
password: 'fixture-noop',
url: FIXTURE_URL,
skipLogin: true,
dto: {
tag: 'TEST',
name: 'Test Project',
platforms: ['B1', 'B2'],
signal_type: 'site',
limit: 25,
workdays: [1, 2, 3, 4, 5],
regions: [],
region_mode: 'include',
domains: ['example.com'],
active: true,
},
});
// ---------------------------------------------------------------------------
// Test 1 — createProject через Element UI фикстуру → external_id из mock-response
// ---------------------------------------------------------------------------
test('createProject fills Element UI form and returns external_id from intercept response', async () => {
const server = await startFixtureServer();
try {
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const out = JSON.parse(result.stdout);
assert.ok(out.external_id, 'external_id should be truthy');
assert.match(out.external_id, /^\d+$/, 'external_id should be numeric string');
const result = await runScript({
operation: 'create',
url,
skipLogin: true,
dto: {
tag: '_lidpotok',
name: 'example.com',
platforms: ['B1'],
signal_type: 'site',
limit: 5,
workdays: [1, 2, 3, 4, 5],
domains: ['example.com'],
region_mode: 'include',
regions: [],
active: true,
},
});
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
let out;
try {
out = JSON.parse(result.stdout);
} catch (e) {
assert.fail(`stdout is not valid JSON: ${result.stdout}\nstderr: ${result.stderr}`);
}
assert.strictEqual(out.external_id, '99001', `expected external_id "99001", got ${JSON.stringify(out)}`);
} finally {
server.close();
}
});
test('listProjects returns array', async () => {
const result = await runScript({
operation: 'list',
login: 'fixture-noop',
password: 'fixture-noop',
url: FIXTURE_URL,
skipLogin: true,
});
// ---------------------------------------------------------------------------
// Test 2 — listProjects в skipLogin-режиме возвращает массив projects
// ---------------------------------------------------------------------------
test('listProjects returns array (skipLogin mode, fixture page)', async () => {
const server = await startFixtureServer();
try {
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const out = JSON.parse(result.stdout);
assert.ok(Array.isArray(out.projects), 'projects should be an array');
const result = await runScript({
operation: 'list',
url,
skipLogin: true,
});
// listOp в skipLogin-режиме не навигирует на /admin/visit/rt — просто открывает url.
// Фикстура не содержит Vuex и таблицы с проектами → возвращает {projects: []}.
assert.strictEqual(result.code, 0, `Expected exit 0. stderr: ${result.stderr}`);
let out;
try {
out = JSON.parse(result.stdout);
} catch (e) {
assert.fail(`stdout is not valid JSON: ${result.stdout}`);
}
assert.ok(Array.isArray(out.projects), `expected projects array, got: ${JSON.stringify(out)}`);
} finally {
server.close();
}
});
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
// Консервативный старт (A1 backend-tooling #64): мёртвый код + качество кода.
// БЕЗ type-declaration наборов и БЕЗ LaravelSetProvider (version-upgrade) на первом
// заходе — их прогоняем вручную при апгрейде Laravel, не как per-commit гейт.
return RectorConfig::configure()
->withPaths([
__DIR__.'/app',
__DIR__.'/database',
__DIR__.'/routes',
])
->withPreparedSets(
deadCode: true,
codeQuality: true,
);
+4 -2
View File
@@ -260,10 +260,12 @@ export interface ApiProject {
}
export async function listProjects(tenantId: number): Promise<ApiProject[]> {
const { data } = await apiClient.get<{ projects: ApiProject[] }>('/api/projects', {
// ProjectController::index() отдаёт { data: ProjectResource::collection(...) }.
// `?? []` — защита от undefined.map в DealsView при нештатном ответе.
const { data } = await apiClient.get<{ data: ApiProject[] }>('/api/projects', {
params: { tenant_id: tenantId },
});
return data.projects;
return data.data ?? [];
}
/**
@@ -6,13 +6,30 @@
*
* Sprint 4 Phase B/3 split DashboardView (audit O-refactor-04 закрытие).
*/
import { computed } from 'vue';
import { useAuthStore } from '../../stores/auth';
const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true });
const auth = useAuthStore();
/** Имя залогиненного пользователя (было захардкожено «Иван»). */
const firstName = computed(() => auth.user?.first_name?.trim() || 'коллега');
/** Приветствие по времени суток (МСК машины пользователя). */
const greeting = computed(() => {
const h = new Date().getHours();
if (h < 6) return 'Доброй ночи';
if (h < 12) return 'Доброе утро';
if (h < 18) return 'Добрый день';
return 'Добрый вечер';
});
</script>
<template>
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
<h1 class="text-h4 mb-2 page-greet">{{ greeting }}, <em class="text-primary">{{ firstName }}</em></h1>
<div class="page-meta text-body-2 text-medium-emphasis">
<span><span class="num text-primary">+3</span> новых лида с утра</span>
<span class="sep">·</span>
@@ -9,6 +9,7 @@ import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useNotificationsStore } from '../../stores/notifications';
import { useCommandPalette } from '../../composables/useCommandPalette';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
defineProps<{
pageTitle: string;
@@ -111,7 +112,7 @@ async function handleLogout(): Promise<void> {
</template>
</v-btn>
<v-menu offset="8" :close-on-content-click="false" location="bottom end">
<v-menu offset="8" :close-on-content-click="false" location="bottom end" @update:model-value="repositionMenuAfterOpen">
<template #activator="{ props: bellProps }">
<v-btn
v-bind="bellProps"
@@ -173,7 +174,7 @@ async function handleLogout(): Promise<void> {
</v-card>
</v-menu>
<v-menu offset="8">
<v-menu offset="8" @update:model-value="repositionMenuAfterOpen">
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
<v-avatar size="28" color="primary" class="mr-2">
+3 -2
View File
@@ -25,14 +25,15 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
];
const route = useRoute();
+7 -1
View File
@@ -41,7 +41,13 @@ const navItems = computed(() => [
]);
const currentPageTitle = computed(() => {
return navItems.value.find((i) => i.to === route.path)?.title ?? 'Страница';
// Сначала короткий title из sidebar-nav (Дашборд/Сделки/), затем route.meta.title
// для страниц вне sidebar (Напоминания, Импорт данных), и только потом fallback.
return (
navItems.value.find((i) => i.to === route.path)?.title ??
(route.meta.title as string | undefined) ??
'Страница'
);
});
async function loadNotifications(): Promise<void> {
+12
View File
@@ -283,6 +283,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Admin Supplier Integration',
},
},
{
path: '/admin/supplier-projects',
name: 'admin-supplier-projects',
component: () => import('../views/admin/AdminSupplierProjectsView.vue'),
meta: {
layout: 'admin',
title: 'Проекты у поставщика',
requiresAuth: true,
devIndex: 31,
devLabel: 'Admin Supplier Projects',
},
},
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
{
path: '/403',
@@ -27,6 +27,38 @@ const loading = ref(false);
const reconciling = ref(false);
const error = ref<string | null>(null);
// --- Plan 4 Task 1: глобальный режим экспорта проектов (online|batch) ---
type ExportMode = 'online' | 'batch';
const exportMode = ref<ExportMode>('batch');
const exportModeError = ref<string | null>(null);
const exportModeSaving = ref(false);
async function loadExportMode(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/export-mode');
if (data?.mode === 'online' || data?.mode === 'batch') {
exportMode.value = data.mode;
}
} catch {
exportModeError.value = 'Не удалось загрузить режим экспорта.';
}
}
async function setExportMode(mode: ExportMode): Promise<void> {
if (exportMode.value === mode) return;
exportModeSaving.value = true;
exportModeError.value = null;
try {
const { data } = await axios.post('/api/admin/supplier-integration/export-mode', { mode });
exportMode.value = data?.mode === 'online' ? 'online' : 'batch';
} catch {
exportModeError.value = 'Не удалось сохранить режим экспорта.';
} finally {
exportModeSaving.value = false;
}
}
async function load(): Promise<void> {
loading.value = true;
error.value = null;
@@ -106,6 +138,7 @@ function formatDate(s: string): string {
onMounted(() => {
void load();
void loadManualQueue();
void loadExportMode();
});
</script>
@@ -113,6 +146,43 @@ onMounted(() => {
<div class="pa-6">
<h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>
<v-card class="mb-4">
<v-card-title>Режим экспорта проектов</v-card-title>
<v-card-text>
<v-alert v-if="exportModeError" type="error" density="compact" class="mb-3">
{{ exportModeError }}
</v-alert>
<div data-testid="export-mode-toggle">
<v-btn-toggle
:model-value="exportMode"
mandatory
color="primary"
density="comfortable"
:disabled="exportModeSaving"
>
<v-btn
data-testid="export-mode-online"
value="online"
@click="setExportMode('online')"
>
Онлайн
</v-btn>
<v-btn
data-testid="export-mode-batch"
value="batch"
@click="setExportMode('batch')"
>
Пакетный
</v-btn>
</v-btn-toggle>
</div>
<p class="text-caption text-medium-emphasis mt-3 mb-0">
Онлайн изменения проекта переносятся к поставщику сразу.
Пакетный ночной синк в 18:00 (SyncSupplierProjectsJob).
</p>
</v-card-text>
</v-card>
<v-card class="mb-4">
<v-card-title>Здоровье резервного канала</v-card-title>
<v-card-text>
@@ -0,0 +1,211 @@
<template>
<div class="admin-supplier-projects-view pa-6">
<h1 class="text-h5 mb-4">Проекты у поставщика</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект
на портале и локальные привязки тенантов (каскадом).
</p>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
class="mb-4"
data-testid="projects-fetch-error"
closable
@click:close="fetchError = null"
>
{{ fetchError }}
</v-alert>
<div class="d-flex align-center mb-3">
<v-btn
color="error"
variant="flat"
prepend-icon="mdi-delete-outline"
data-testid="bulk-delete-btn"
:disabled="selected.length === 0"
:loading="deleting"
@click="confirmOpen = true"
>
Удалить выбранные ({{ selected.length }})
</v-btn>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load">
Обновить
</v-btn>
</div>
<v-card elevation="1">
<v-data-table
:headers="headers"
:items="projects"
:loading="loading"
density="comfortable"
item-value="id"
>
<template #[`item.select`]="{ item }">
<v-checkbox
:model-value="selected.includes(item.id)"
:data-testid="`row-checkbox-${item.id}`"
hide-details
density="compact"
@update:model-value="(v: boolean | null) => toggleRow(item.id, v)"
/>
</template>
<template #[`item.orderers`]="{ item }">
<span v-if="item.orderers.length">{{ item.orderers.join(', ') }}</span>
<span v-else class="text-medium-emphasis"></span>
</template>
<template #[`item.last_delivery_at`]="{ item }">
{{ item.last_delivery_at ? formatDate(item.last_delivery_at) : '—' }}
</template>
</v-data-table>
</v-card>
<v-dialog v-model="confirmOpen" max-width="480">
<v-card>
<v-card-title>Удалить выбранные проекты?</v-card-title>
<v-card-text>
Будет удалено проектов: <strong>{{ selected.length }}</strong>.
Действие снимает проекты у поставщика и локальные привязки.
Отменить нельзя.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Отмена</v-btn>
<v-btn
color="error"
variant="flat"
data-testid="confirm-delete-btn"
:loading="deleting"
@click="performDelete"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbarOpen"
:timeout="4000"
:color="snackbarColor"
location="bottom right"
data-testid="projects-snackbar"
>
{{ snackbarText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';
/**
* SaaS-admin «Проекты у поставщика» (Plan 4 Task 3).
*
* Backend: AdminSupplierIntegrationController::projectsIndex / projectsDestroy.
* Список supplier_projects + кто заказывал (orderers) + дата последней поставки;
* bulk-delete выбранных (портал + локально каскадом).
*/
interface SupplierProjectRow {
id: number;
platform: string;
signal_type: string;
unique_key: string;
subject_code: number | null;
subject_name: string | null;
current_limit: number;
supplier_external_id: string | null;
orderers: string[];
last_delivery_at: string | null;
}
const projects = ref<SupplierProjectRow[]>([]);
const selected = ref<number[]>([]);
const loading = ref(false);
const deleting = ref(false);
const fetchError = ref<string | null>(null);
const confirmOpen = ref(false);
const snackbarOpen = ref(false);
const snackbarText = ref('');
const snackbarColor = ref<'success' | 'warning' | 'error'>('success');
const headers = [
{ title: '', key: 'select', sortable: false, width: 56 },
{ title: 'Источник', key: 'unique_key', sortable: true },
{ title: 'Платформа', key: 'platform', sortable: true, width: 110 },
{ title: 'Регион', key: 'subject_name', sortable: true },
{ title: 'Лимит', key: 'current_limit', sortable: true, width: 90 },
{ title: 'Кто заказывал', key: 'orderers', sortable: false },
{ title: 'Последняя поставка', key: 'last_delivery_at', sortable: true, width: 180 },
];
function toggleRow(id: number, value: boolean | null): void {
if (value) {
if (!selected.value.includes(id)) selected.value.push(id);
} else {
selected.value = selected.value.filter((x) => x !== id);
}
}
function formatDate(s: string): string {
return new Date(s).toLocaleString('ru-RU');
}
async function load(): Promise<void> {
loading.value = true;
fetchError.value = null;
try {
const { data } = await axios.get('/api/admin/supplier-integration/projects');
projects.value = Array.isArray(data?.projects) ? data.projects : [];
// Снять выбор с уже удалённых строк.
const ids = new Set(projects.value.map((p) => p.id));
selected.value = selected.value.filter((id) => ids.has(id));
} catch {
fetchError.value = 'Не удалось загрузить список проектов.';
} finally {
loading.value = false;
}
}
async function performDelete(): Promise<void> {
if (selected.value.length === 0) {
confirmOpen.value = false;
return;
}
deleting.value = true;
try {
const { data } = await axios.post('/api/admin/supplier-integration/projects/delete', {
ids: selected.value,
});
const deleted = Number(data?.deleted ?? 0);
const failures = Array.isArray(data?.failures) ? data.failures : [];
if (failures.length > 0) {
snackbarColor.value = 'warning';
snackbarText.value = `Удалено: ${deleted}. Не удалось: ${failures.length}.`;
} else {
snackbarColor.value = 'success';
snackbarText.value = `Удалено проектов: ${deleted}.`;
}
snackbarOpen.value = true;
confirmOpen.value = false;
selected.value = [];
await load();
} catch {
snackbarColor.value = 'error';
snackbarText.value = 'Ошибка при удалении проектов.';
snackbarOpen.value = true;
} finally {
deleting.value = false;
}
}
onMounted(load);
defineExpose({ load, performDelete, toggleRow, projects, selected, confirmOpen });
</script>
@@ -86,17 +86,20 @@
/>
<v-autocomplete
v-model="form.regions"
:model-value="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Регионы (пусто = вся РФ)"
label="Регионы"
:disabled="vsyaRfConfirmed"
multiple
chips
clearable
density="comfortable"
class="ld-input-quiet"
data-testid="regions-autocomplete"
:error-messages="errors.regions"
@update:model-value="onRegionsChange"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
@@ -108,6 +111,51 @@
</template>
</v-autocomplete>
<v-checkbox
:model-value="vsyaRf"
label="Вся РФ (все регионы)"
density="comfortable"
hide-details
data-testid="vsya-rf-checkbox"
@update:model-value="(v: boolean | null) => (v ? chooseVsyaRf() : cancelVsyaRf())"
/>
<v-alert
v-if="vsyaRf && !vsyaRfConfirmed"
type="warning"
variant="tonal"
density="compact"
class="mt-2"
data-testid="vsya-rf-warning"
>
Вы выбрали всю Россию проект будет получать лиды по всем регионам
(всем субъектам РФ). Подтвердите, что это намеренно.
<div class="mt-2">
<v-btn
size="small"
color="warning"
variant="flat"
data-testid="confirm-vsya-rf"
@click="confirmVsyaRf"
>
Подтверждаю «Вся РФ»
</v-btn>
<v-btn size="small" variant="text" class="ml-2" @click="cancelVsyaRf">
Отмена
</v-btn>
</div>
</v-alert>
<v-chip
v-else-if="vsyaRfConfirmed"
color="success"
size="small"
class="mt-2"
data-testid="vsya-rf-confirmed"
>
Вся РФ подтверждено
</v-chip>
<v-alert
v-if="generalError"
type="error"
@@ -176,6 +224,38 @@ const errors = reactive<Record<string, string[]>>({});
const saving = ref(false);
const generalError = ref<string | null>(null);
// Plan 4 Task 4: обязательный выбор региона + явная «Вся РФ» с подтверждением.
// vsyaRf чекбокс выбран; vsyaRfConfirmed подтверждён через предупреждение.
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы гейт намеренно UI-only.
const vsyaRf = ref(false);
const vsyaRfConfirmed = ref(false);
function chooseVsyaRf(): void {
vsyaRf.value = true;
vsyaRfConfirmed.value = false;
}
function confirmVsyaRf(): void {
vsyaRfConfirmed.value = true;
form.regions = []; // Вся РФ пустой массив субъектов
delete errors.regions;
}
function cancelVsyaRf(): void {
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
}
function onRegionsChange(codes: number[]): void {
form.regions = Array.isArray(codes) ? codes : [];
if (form.regions.length > 0) {
// Взаимоисключение: выбор конкретных субъектов снимает «Вся РФ».
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
delete errors.regions;
}
}
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
watch(selectedDays, (days) => {
@@ -191,12 +271,18 @@ watch(
() => props.modelValue,
(open) => {
if (open) generalError.value = null;
if (open) {
delete errors.regions;
}
if (open && props.mode === 'edit' && props.project) {
Object.assign(form, props.project);
form.regions = Array.isArray(props.project.regions) ? [...props.project.regions] : [];
const days: number[] = [];
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
selectedDays.value = days;
// Существующий проект с пустыми регионами = «Вся РФ» (предзаполняем подтверждённым).
vsyaRf.value = form.regions.length === 0;
vsyaRfConfirmed.value = form.regions.length === 0;
} else if (open) {
Object.assign(form, {
name: '',
@@ -209,14 +295,24 @@ watch(
delivery_days_mask: 127,
});
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
vsyaRf.value = false;
vsyaRfConfirmed.value = false;
}
},
{ immediate: true },
);
async function submit() {
saving.value = true;
generalError.value = null;
Object.keys(errors).forEach((k) => delete errors[k]);
// Гейт обязательного региона: нужны либо субъекты, либо подтверждённая «Вся РФ».
if (form.regions.length === 0 && !vsyaRfConfirmed.value) {
errors.regions = ['Выберите регион или подтвердите «Вся РФ»'];
return;
}
saving.value = true;
try {
await ensureCsrfCookie();
if (props.mode === 'edit' && props.project) {
@@ -241,6 +337,8 @@ async function submit() {
function close() {
emit('update:modelValue', false);
}
defineExpose({ chooseVsyaRf, confirmVsyaRf, cancelVsyaRf, onRegionsChange, vsyaRf, vsyaRfConfirmed, form, submit });
</script>
<style scoped>
+8
View File
@@ -154,6 +154,14 @@ Route::middleware('saas-admin')->group(function () {
Route::get('/api/admin/supplier-integration/manual-queue', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueIndex');
Route::post('/api/admin/supplier-integration/manual-queue/{id}/resolve', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueResolve')
->where('id', '[0-9]+');
// Plan 4 Task 1: глобальный тумблер режима экспорта проектов (online|batch).
Route::get('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getExportMode');
Route::post('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setExportMode');
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* ВРЕМЕННЫЙ demo-скрипт разбивает 5 тестовых пользователей на 5 отдельных тенантов.
* Каждый логин = своя компания, данные изолированы.
* Идемпотентный: повторный запуск не дублирует тенанты.
* Запуск: php artisan tinker storage/_demo_split_tenants.php
*/
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Str;
// -------------------------------------------------------------------
// 1. Проверяем исходное состояние
// -------------------------------------------------------------------
$totalBefore = User::count();
$tenantsBefore = Tenant::count();
echo "=== ДО: {$totalBefore} пользователей, {$tenantsBefore} тенант(ов) ===\n\n";
// -------------------------------------------------------------------
// 2. Описание каждого пользователя и его будущего тенанта
// -------------------------------------------------------------------
$accounts = [
[
'email' => 'admin@demo.local',
'tenant_subdomain' => 'demo', // оставляем существующий тенант
'org_name' => null, // null = взять из существующего
'create_new_tenant' => false,
],
[
'email' => 'manager1@demo.local',
'tenant_subdomain' => 'ivan-demo',
'org_name' => 'Компания Ивана',
'create_new_tenant' => true,
],
[
'email' => 'manager2@demo.local',
'tenant_subdomain' => 'anna-demo',
'org_name' => 'Компания Анны',
'create_new_tenant' => true,
],
[
'email' => 'manager3@demo.local',
'tenant_subdomain' => 'petr-demo',
'org_name' => 'Компания Петра',
'create_new_tenant' => true,
],
[
'email' => 'manager4@demo.local',
'tenant_subdomain' => 'mariya-demo',
'org_name' => 'Компания Марии',
'create_new_tenant' => true,
],
];
// -------------------------------------------------------------------
// 3. Создаём тенанты и переназначаем пользователей
// -------------------------------------------------------------------
foreach ($accounts as $a) {
$user = User::query()->where('email', $a['email'])->firstOrFail();
if (! $a['create_new_tenant']) {
// Demo Admin остаётся в tenant "demo"
$tenant = Tenant::query()->where('subdomain', $a['tenant_subdomain'])->firstOrFail();
echo "SKIP {$user->email} → тенант «{$tenant->organization_name}» (id={$tenant->id}) — без изменений\n";
continue;
}
// Создаём новый тенант, если ещё не существует
$tenant = Tenant::query()->firstOrCreate(
['subdomain' => $a['tenant_subdomain']],
[
'organization_name' => $a['org_name'],
'contact_email' => $user->email,
'webhook_token' => Str::random(64),
'timezone' => 'Europe/Moscow',
'locale' => 'ru',
'is_trial' => true,
'api_key_limit' => 5,
]
);
// Переназначаем пользователя в новый тенант
$user->tenant_id = $tenant->id;
$user->save();
echo "OK {$user->email} → новый тенант «{$tenant->organization_name}» (id={$tenant->id}, subdomain={$tenant->subdomain})\n";
}
// -------------------------------------------------------------------
// 4. Итоговый отчёт
// -------------------------------------------------------------------
echo "\n=== ИТОГО: изоляция тенантов ===\n";
$tenants = Tenant::query()
->whereIn('subdomain', ['demo', 'ivan-demo', 'anna-demo', 'petr-demo', 'mariya-demo'])
->orderBy('id')
->get();
foreach ($tenants as $t) {
$users = User::query()->where('tenant_id', $t->id)->pluck('email')->implode(', ');
$projects = Project::query()->where('tenant_id', $t->id)->count();
echo sprintf(
" Тенант %-12s (id=%-2d) — пользователи: %-40s | проектов: %d\n",
$t->subdomain,
$t->id,
$users ?: '(нет)',
$projects
);
}
echo "\nГотово. Каждый логин теперь в отдельной компании.\n";
echo "Пароль для всех: password\n";
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
// EnsureSaasAdmin — стаб в testing (см. SupplierManualQueueTest::16); actingAs
// нужен только для прохода middleware-стека auth+admin.
it('GET export-mode returns current value', function (): void {
$this->actingAs(User::factory()->create());
DB::table('system_settings')->updateOrInsert(
['key' => 'supplier_export_mode'],
['value' => 'batch', 'type' => 'string', 'updated_at' => now()],
);
$this->getJson('/api/admin/supplier-integration/export-mode')
->assertOk()
->assertJson(['mode' => 'batch']);
});
it('POST export-mode switches value', function (): void {
$this->actingAs(User::factory()->create());
DB::table('system_settings')->updateOrInsert(
['key' => 'supplier_export_mode'],
['value' => 'batch', 'type' => 'string', 'updated_at' => now()],
);
$this->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'online'])
->assertOk()
->assertJson(['mode' => 'online']);
expect(DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value'))
->toBe('online');
});
it('POST export-mode rejects invalid value', function (): void {
$this->actingAs(User::factory()->create());
$this->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'turbo'])
->assertStatus(422);
});
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
// EnsureSaasAdmin — стаб в testing (см. SupplierManualQueueTest::16): обычный
// User::factory + actingAs без guard'а.
it('GET /admin/supplier-integration/projects returns rows with orderers + last delivery', function (): void {
$this->actingAs(User::factory()->create());
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Ромашка']);
$sp = SupplierProject::query()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'okna.ru',
'subject_code' => 82, // Москва (по конституционному порядку, ст. 65)
'current_limit' => 5,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
'supplier_external_id' => '777',
]);
$project = Project::factory()->for($tenant)->create();
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => 'B1',
'subject_code' => 82,
]);
DB::table('supplier_leads')->insert([
'supplier_project_id' => $sp->id,
'platform' => 'B1',
'raw_payload' => json_encode([]),
'phone' => '+79991234567',
'received_at' => '2026-05-19 10:00:00',
]);
$resp = $this->getJson('/api/admin/supplier-integration/projects')
->assertOk()
->json();
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
expect($row)->not->toBeNull()
->and($row['unique_key'])->toBe('okna.ru')
->and($row['subject_code'])->toBe(82)
->and($row['subject_name'])->toBe('Москва')
->and($row['platform'])->toBe('B1')
->and($row['current_limit'])->toBe(5)
->and($row['orderers'])->toContain('ООО Ромашка')
->and($row['last_delivery_at'])->not->toBeNull();
});
it('GET /projects returns subject_name «РФ» for NULL subject_code', function (): void {
$this->actingAs(User::factory()->create());
$sp = SupplierProject::query()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'all-russia.example',
'subject_code' => null,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
'supplier_external_id' => '888',
]);
$resp = $this->getJson('/api/admin/supplier-integration/projects')->assertOk()->json();
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
expect($row['subject_code'])->toBeNull()
->and($row['subject_name'])->toBe('РФ');
});
it('POST /projects/delete deletes on portal + locally (pivot cascades)', function (): void {
$this->actingAs(User::factory()->create());
// Мокаем portal-клиент, чтобы не лезть в Redis-сессию (SupplierPortalClient::loadSession()).
$deletedExternalIds = [];
$clientMock = new class($deletedExternalIds) extends SupplierPortalClient
{
/** @var array<int, int> */
public array $calls;
public function __construct(array &$calls)
{
$this->calls = &$calls;
}
public function deleteProject(int $externalId): void
{
$this->calls[] = $externalId;
}
};
app()->instance(SupplierPortalClient::class, $clientMock);
$tenant = Tenant::factory()->create();
$sp = SupplierProject::query()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'delete-me.ru',
'subject_code' => 77,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
'supplier_external_id' => '999',
]);
$project = Project::factory()->for($tenant)->create();
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => 'B1',
'subject_code' => 77,
]);
$this->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => [$sp->id]])
->assertOk()
->assertJson(['deleted' => 1, 'failures' => []]);
expect(SupplierProject::find($sp->id))->toBeNull();
expect($clientMock->calls)->toBe([999]);
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->count())->toBe(0);
});
it('POST /projects/delete validates ids array', function (): void {
$this->actingAs(User::factory()->create());
$this->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => []])
->assertStatus(422);
$this->postJson('/api/admin/supplier-integration/projects/delete', [])
->assertStatus(422);
});
it('POST /projects/delete collects failures without aborting batch', function (): void {
$this->actingAs(User::factory()->create());
$clientMock = new class extends SupplierPortalClient
{
public int $callsCount = 0;
public function __construct() {}
public function deleteProject(int $externalId): void
{
$this->callsCount++;
if ($externalId === 555) {
throw new RuntimeException('portal said no');
}
}
};
app()->instance(SupplierPortalClient::class, $clientMock);
$spOk = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'ok.ru',
'subject_code' => 77, 'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => null,
'sync_status' => 'ok', 'supplier_external_id' => '111',
]);
$spBad = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
'subject_code' => 77, 'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => null,
'sync_status' => 'ok', 'supplier_external_id' => '555',
]);
$resp = $this->postJson('/api/admin/supplier-integration/projects/delete', [
'ids' => [$spOk->id, $spBad->id],
])->assertOk()->json();
expect($resp['deleted'])->toBe(1)
->and(count($resp['failures']))->toBe(1)
->and($resp['failures'][0]['id'])->toBe($spBad->id)
->and($resp['failures'][0]['error'])->toContain('portal said no');
expect(SupplierProject::find($spOk->id))->toBeNull();
expect(SupplierProject::find($spBad->id))->not->toBeNull(); // bad — не удалён локально
});
@@ -36,7 +36,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$tenants->push($t);
$projects->push(Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
@@ -49,18 +49,24 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
'region_mode' => 'include',
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
]));
]);
$projects->push($project);
// v8.26 (Plan 1-2): LeadRouter eligibility — через pivot project_supplier_links,
// не legacy supplier_b1_project_id. Без pivot-связи проект не eligible → 0 сделок.
linkProjectToSupplier($project, $supplier);
}
// 4-й tenant — paused
// 4-й tenant — paused (is_active=false). Связь в pivot есть, чтобы проверялся
// именно фильтр is_active, а не отсутствие связи.
$pausedTenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$pausedProject = Project::factory()->create([
'tenant_id' => $pausedTenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => false,
]);
linkProjectToSupplier($pausedProject, $supplier);
$vid = 432176649;
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
@@ -27,19 +27,23 @@ test('supplier_projects table exists with required columns', function () {
}
});
test('supplier_projects has unique constraint on (platform, unique_key)', function () {
test('supplier_projects has unique constraint on (platform, unique_key, subject_code)', function () {
// v8.26 (project-migration-redesign Plan 1): per-субъект экспорт — composite unique
// расширен до (platform, unique_key, subject_code) NULLS NOT DISTINCT. Старый
// 2-колоночный индекс supplier_projects_platform_unique_key_unique заменён.
$idx = DB::selectOne(
"SELECT indexdef
FROM pg_indexes
WHERE tablename = 'supplier_projects'
AND indexname = 'supplier_projects_platform_unique_key_unique'"
AND indexname = 'supplier_projects_platform_key_subject_unique'"
);
expect($idx)->not->toBeNull();
expect($idx->indexdef)
->toContain('UNIQUE')
->toContain('platform')
->toContain('unique_key');
->toContain('unique_key')
->toContain('subject_code');
});
test('supplier_projects platform check constraint allows only B1, B2, B3', function () {
@@ -10,14 +10,18 @@ use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
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\Collection;
use Illuminate\Support\Facades\DB;
use Mockery as M;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
@@ -37,9 +41,13 @@ function runRouteJob(int $supplierLeadId): void
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
@@ -61,6 +69,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
'delivered_today' => 0,
'delivered_in_month' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
}
$vid = 432176649;
@@ -108,13 +117,14 @@ it('decrements balance_leads for each tenant by 1', function (): void {
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'test.ru',
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
$vid = 99;
$lead = SupplierLead::factory()->create([
@@ -145,6 +155,7 @@ it('marks duplicate via DuplicateDetector — no charge, no counter increment',
'is_active' => true,
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$master = Deal::create([
@@ -229,6 +240,7 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
'delivered_today' => 0,
'delivered_in_month' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
}
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
@@ -299,6 +311,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
'is_active' => true,
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
$vid = 7777;
$lead = SupplierLead::factory()->create([
@@ -367,6 +380,7 @@ it('handles partial failure: one project throws, others continue routing', funct
'is_active' => true,
'delivered_today' => 0,
]));
linkProjectToSupplier($projects->last(), $supplier);
}
// Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
@@ -403,13 +417,14 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car
'unique_key' => $domain,
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => $domain,
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
$vid = random_int(100000, 999999);
$lead = SupplierLead::factory()->create([
@@ -509,3 +524,48 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
});
it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
// seeded distributor — детерминизм
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(
new Randomizer(new Mt19937(7))
));
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
foreach (range(1, 5) as $i) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$p = Project::factory()->create([
'tenant_id' => $t->id, 'is_active' => true,
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
]);
linkProjectToSupplier($p, $sp);
}
$lead = SupplierLead::factory()->create([
'phone' => '79991234567',
'vid' => 555111,
'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
'processed_at' => null,
'supplier_project_id' => null,
'platform' => 'B1',
]);
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
$deals = Deal::query()->where('source_crm_id', 555111)->get();
expect($deals)->toHaveCount(3)
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
});
@@ -59,26 +59,28 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.25 has correct metrics — 64 base tables, 121 indexes, 40 RLS policies', function () {
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.25.
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26.
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
// v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot)
// + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*).
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
$schema = file_get_contents($schemaPath);
expect($schema)->not->toBeFalse();
// 64 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
// 65 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(64);
expect($baseTables)->toBe(65);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(121); // v8.25: +2 idx_smsq_status_created, idx_smsq_project
expect($createIndexes)->toBe(123); // v8.26: +2 supplier_projects_platform_key_subject_unique, idx_psl_*
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(40);
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Queue;
beforeEach(fn () => Queue::fake());
it('updates name+daily_limit without resync', function () {
it('updates name without resync (name is local-only)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
@@ -19,14 +19,45 @@ it('updates name+daily_limit without resync', function () {
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'name' => 'New name', 'daily_limit_target' => 50,
'name' => 'New name',
])->assertOk();
expect($project->fresh()->name)->toBe('New name');
expect($project->fresh()->daily_limit_target)->toBe(50);
Queue::assertNotPushed(SyncSupplierProjectJob::class);
});
it('changing daily_limit_target triggers resync (poster must see new limit immediately)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'signal_type' => 'site',
'signal_identifier' => 'a.ru', 'daily_limit_target' => 10,
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'daily_limit_target' => 50,
])->assertOk();
expect($project->fresh()->daily_limit_target)->toBe(50);
Queue::assertPushed(SyncSupplierProjectJob::class);
});
it('changing delivery_days_mask triggers resync (poster must see new days immediately)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'signal_type' => 'site',
'signal_identifier' => 'a.ru', 'delivery_days_mask' => 31,
]);
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
'delivery_days_mask' => 63, // +Сб
])->assertOk();
expect($project->fresh()->delivery_days_mask)->toBe(63);
Queue::assertPushed(SyncSupplierProjectJob::class);
});
it('changing sms_senders triggers resync', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
+74 -101
View File
@@ -19,63 +19,74 @@ beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
it('returns matching active projects for B1 site supplier_project (sharing across tenants)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'vashinvestor.ru',
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
it('returns project linked via pivot to the supplier_project', function (): void {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
]);
linkProjectToSupplier($project, $sp);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(1)
->and($matched->first()->id)->toBe($project->id);
});
it('excludes project NOT linked to this supplier_project', function (): void {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r2.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
]); // не линкуем
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
});
it('excludes inactive project, project at limit, and zero-balance tenant', function (): void {
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r3.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$tenant1 = Tenant::factory()->create(['balance_leads' => 100]);
$tenant2 = Tenant::factory()->create(['balance_leads' => 100]);
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
$inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
linkProjectToSupplier($inactive, $sp);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
$atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
linkProjectToSupplier($atLimit, $sp);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
$t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
$broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
linkProjectToSupplier($broke, $sp);
$router = app(LeadRouter::class);
$matched = $router->matchEligibleProjects($supplier, '79991234567');
expect($matched)->toHaveCount(2);
expect($matched->pluck('id')->all())->toEqualCanonicalizing([$project1->id, $project2->id]);
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
});
it('skips paused project (is_active=false)', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => false,
]);
linkProjectToSupplier($project, $supplier);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
});
it('skips project where today is not in delivery_days_mask', function (): void {
@@ -87,44 +98,43 @@ it('skips project where today is not in delivery_days_mask', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
'delivery_days_mask' => $maskWithoutToday,
]);
linkProjectToSupplier($project, $supplier);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
});
it('skips project where delivered_today >= effective_daily_limit_today', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
'effective_daily_limit_today' => 5,
'delivered_today' => 5,
]);
linkProjectToSupplier($project, $supplier);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
});
it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
@@ -132,28 +142,10 @@ it('falls back to daily_limit_target when effective_daily_limit_today is null',
'daily_limit_target' => 10,
'delivered_today' => 5,
]);
linkProjectToSupplier($project, $supplier);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
});
it('skips project where region_mode=include and region_mask does not include phone district', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
'region_mask' => 1, // только Центральный округ
'region_mode' => 'include',
]);
$router = app(LeadRouter::class);
// 78121234567 = СПб (Северо-Западный, бит 2)
expect($router->matchEligibleProjects($supplier, '78121234567'))->toHaveCount(0);
expect($router->matchEligibleProjects($supplier))->toHaveCount(1);
});
it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (Plan 4 dual-balance)', function (): void {
@@ -162,16 +154,16 @@ it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (P
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '0.00']);
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
});
it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balance rub-only tenant)', function (): void {
@@ -182,56 +174,37 @@ it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balan
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
]);
linkProjectToSupplier($project, $supplier);
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
$eligible = $router->matchEligibleProjects($supplier);
expect($eligible)->toHaveCount(1);
expect($eligible->first()->id)->toBe($project->id);
});
it('routes through correct FK based on platform (B2 → supplier_b2_project_id)', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => null,
'supplier_b2_project_id' => $supplier->id,
'supplier_b3_project_id' => null,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
]);
$router = app(LeadRouter::class);
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
});
it('orders results by created_at ASC (deterministic, spec §6 step 4)', function (): void {
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
$projectsCreated = collect();
for ($i = 0; $i < 3; $i++) {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$projectsCreated->push(
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
'created_at' => now()->subDays(3 - $i),
])
);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'example.com',
'is_active' => true,
'created_at' => now()->subDays(3 - $i),
]);
linkProjectToSupplier($project, $supplier);
$projectsCreated->push($project);
}
$router = app(LeadRouter::class);
$matched = $router->matchEligibleProjects($supplier, '79991234567');
$matched = $router->matchEligibleProjects($supplier);
expect($matched->pluck('id')->all())->toBe($projectsCreated->pluck('id')->all());
});
@@ -11,8 +11,10 @@ use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
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;
@@ -47,6 +49,7 @@ function makeFlowWithBalance(array $balance): array
'effective_daily_limit_today' => 10, 'delivered_today' => 0,
'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($project, $supplierProject);
$lead = SupplierLead::factory()->create([
'vid' => random_int(100_000_000, 999_999_999),
'phone' => '79991234567',
@@ -66,6 +69,8 @@ function runJob(int $leadId): void
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
@@ -151,12 +156,14 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($projectA, $supplierProject);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com',
'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true,
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($projectB, $supplierProject);
$lead = SupplierLead::factory()->create([
'vid' => random_int(100_000_000, 999_999_999),
'phone' => '79991234567',
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('backfills pivot rows from legacy supplier_b{1,2,3}_project_id slots', function (): void {
$sp1 = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$sp3 = SupplierProject::query()->create([
'platform' => 'B3', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$project = Project::factory()->create([
'supplier_b1_project_id' => $sp1->id,
'supplier_b3_project_id' => $sp3->id,
]);
// Симулируем «до бэкофилла»: pivot пуст.
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
// Запуск логики бэкофилла повторно (миграция идемпотентна).
require_once base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php');
(include base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php'))->up();
$rows = DB::table('project_supplier_links')->where('project_id', $project->id)->get();
expect($rows)->toHaveCount(2)
->and($rows->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B3']);
});
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Schema;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('deals has nullable subject_code column', function (): void {
expect(Schema::hasColumn('deals', 'subject_code'))->toBeTrue();
});
it('rejects subject_code out of 1..89 range', function (): void {
expect(fn () => Deal::factory()->create(['subject_code' => 90]))
->toThrow(QueryException::class);
});
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
Mail::fake();
config(['services.supplier.alert_email' => 'ops@liderra.local']);
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = mock(SupplierProjectChannel::class);
$tier1->shouldReceive('listProjects')->andReturn([]); // dedup-сверка: нет совпадений
$tier1->shouldReceive('createProject')->andThrow(new SupplierClientException('Tier-1 mock fail'));
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
);
expect(fn () => $channel->createProjectForLiderra($project, $dto))
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
});
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
Mail::fake();
config(['services.supplier.alert_email' => 'ops@liderra.local']);
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = mock(SupplierProjectChannel::class);
$tier1->shouldReceive('listProjects')->andReturn([]);
$tier1->shouldReceive('createProject')->andThrow(new SupplierTransientException('Connection refused'));
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
);
expect(fn () => $channel->createProjectForLiderra($project, $dto))
->toThrow(TierEscalatedException::class);
$row = SupplierManualSyncQueue::where('project_id', $project->id)->first();
expect($row->failure_reason)->toBe('portal_unreachable');
});
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('creates pivot row linking project to supplier_project', function (): void {
$project = Project::factory()->create();
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'link.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => 'B1',
'subject_code' => 82,
]);
expect(DB::table('project_supplier_links')
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->exists())->toBeTrue();
});
it('cascades pivot deletion when supplier_project is deleted', function (): void {
$project = Project::factory()->create();
$sp = SupplierProject::query()->create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'cascade.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B2', 'subject_code' => 82,
]);
$sp->delete();
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->exists())->toBeFalse();
});
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('links project to supplier projects via belongsToMany pivot', function (): void {
$project = Project::factory()->create();
$sp1 = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$sp2 = SupplierProject::query()->create([
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$project->supplierProjects()->attach([
$sp1->id => ['platform' => 'B1', 'subject_code' => 82],
$sp2->id => ['platform' => 'B2', 'subject_code' => 82],
]);
expect($project->supplierProjects()->count())->toBe(2)
// @phpstan-ignore-next-line argument.type — qualified 'projects.id' (belongsToMany disambiguator)
->and($sp1->projects()->pluck('projects.id')->all())->toContain($project->id)
// @phpstan-ignore-next-line property.notFound — withPivot adds dynamic 'pivot' accessor
->and($project->supplierProjects->first()->pivot->platform)->not->toBeNull();
});
@@ -13,8 +13,10 @@ use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
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;
@@ -66,6 +68,7 @@ function prepareSharingFlow(int $tenantsCount, array $balances): array
'delivery_days_mask' => 127,
'region_mask' => 255,
]);
linkProjectToSupplier($project, $supplierProject);
$tenants[] = $tenant;
$projects[] = $project;
}
@@ -90,6 +93,8 @@ function dispatchJob(int $supplierLeadId): void
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
@@ -81,23 +81,27 @@ test("LeadRouter видит проекты всех tenant'ов под pgsql_sup
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
foreach ($tenants as $tenant) {
for ($i = 0; $i < 2; $i++) {
Project::factory()->create([
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'plan3-task3-warn2.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
'subject_code' => $supplier->subject_code,
]);
}
}
$router = app(LeadRouter::class);
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
$eligible = $router->matchEligibleProjects($supplier);
expect($eligible)->toHaveCount(6);
});
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
it('seeds supplier_export_mode = batch by default', function (): void {
$row = DB::table('system_settings')->where('key', 'supplier_export_mode')->first();
expect($row)->not->toBeNull()
->and($row->value)->toBe('batch')
->and($row->type)->toBe('string');
});
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\SupplierExportMode;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('reads mode from system_settings, defaults batch', function (): void {
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
expect(SupplierExportMode::current())->toBe('online')
->and(SupplierExportMode::isOnline())->toBeTrue();
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
expect(SupplierExportMode::current())->toBe('batch')
->and(SupplierExportMode::isOnline())->toBeFalse();
});
it('falls back to batch when setting missing', function (): void {
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
expect(SupplierExportMode::current())->toBe('batch');
});
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Support\Facades\Http;
it('multi-flag save returns external_id per platform via listProjects', function (): void {
Http::fake([
'*/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'id' => '300'], 200),
// Real portal returns name='B1_<identifier>' with the identifier in 'content'.
'*/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '100', 'name' => 'B1_okna.ru', 'content' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
['id' => '200', 'name' => 'B2_okna.ru', 'content' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
['id' => '300', 'name' => 'B3_okna.ru', 'content' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
['id' => '999', 'name' => 'B1_other.ru', 'content' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
]], 200),
]);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'okna.ru', limit: 9,
workdays: [1, 2, 3, 4, 5, 6, 7], regions: [82], regionsReverse: false, status: 'active',
tag: 'Москва', platforms: ['B1', 'B2', 'B3'],
);
$ids = app(SupplierPortalClient::class)->saveProjectMultiFlag($dto);
expect($ids)->toBe(['B1' => 100, 'B2' => 200, 'B3' => 300]);
});
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Models\SupplierProject;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('allows same (platform, unique_key) with different subject_code', function (): void {
SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
'subject_code' => 83, 'current_limit' => 0, 'sync_status' => 'ok',
]);
expect(SupplierProject::query()->where('unique_key', 'okna.ru')->count())->toBe(2);
});
it('rejects duplicate (platform, unique_key, subject_code) including NULL pool', function (): void {
SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
]);
expect(fn () => SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
]))->toThrow(QueryException::class);
});
it('rejects subject_code out of 1..89 range', function (): void {
expect(fn () => SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
'subject_code' => 90, 'current_limit' => 0, 'sync_status' => 'ok',
]))->toThrow(QueryException::class);
});
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'sess123',
'csrf' => 'csrf123',
'refreshed_at' => now()->toIso8601String(),
], now()->addHours(6));
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
config(['services.supplier.alert_email' => 'ops@liderra.test']);
// Default to batch mode so existing Plan5 tests are unaffected
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
});
afterEach(function (): void {
Cache::store('redis')->forget('supplier:session');
Carbon::setTestNow();
});
// ---------------------------------------------------------------------------
// Online mode: single-group supplier_projects + pivot
// ---------------------------------------------------------------------------
it('online mode creates single-group supplier_projects with full regions + pivot', function (): void {
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
'is_active' => true,
'daily_limit_target' => 12,
'regions' => [82],
'delivery_days_mask' => 127,
]);
// saveProjectMultiFlag → rt-project-save + listProjects → 3 ids
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '1001', 'src' => 'rt', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
['id' => '1002', 'src' => 'bl', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
['id' => '1003', 'src' => 'mt', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
]],
200,
),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// 3 supplier_projects: subject_code=null (single group), platforms B1/B2/B3
expect(SupplierProject::where('unique_key', 'okna.ru')->whereNull('subject_code')->count())->toBe(3);
// pivot: 3 links for this project
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79135191264',
'is_active' => true,
'daily_limit_target' => 15,
'regions' => [],
'delivery_days_mask' => 31, // Пн-Пт
]);
$capturedWorkdays = null;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedWorkdays) {
$body = $request->data();
if (isset($body['workdays'])) {
$capturedWorkdays = $body['workdays'];
}
return Http::response(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '2001', 'src' => 'rt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
['id' => '2002', 'src' => 'bl', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
['id' => '2003', 'src' => 'mt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
]],
200,
),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// 1) supplier_projects записаны с реальными буднями, не all-7.
$sps = SupplierProject::where('unique_key', '79135191264')->get();
expect($sps)->toHaveCount(3);
foreach ($sps as $sp) {
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
}
// 2) HTTP payload к порталу содержал ["1","2","3","4","5"], не ["1".."7"].
expect($capturedWorkdays)->toBe(['1', '2', '3', '4', '5']);
});
it('online mode update-path: existing supplier_projects.current_workdays is refreshed (not just regions/limit)', function (): void {
// Regression: forceFill ранее не включал current_workdays — после первого create со
// старым хардкод-[1..7] последующий ресинк не подтягивал реальные дни.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79991234567',
'is_active' => true,
'daily_limit_target' => 9,
'regions' => [],
'delivery_days_mask' => 31, // Пн-Пт
]);
// Pre-seed existing supplier_projects со старыми (хардкод-)workdays.
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::create([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79991234567',
'subject_code' => null,
'supplier_external_id' => '99'.$platform,
'current_limit' => 6,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$sps = SupplierProject::where('unique_key', '79991234567')->get();
expect($sps)->toHaveCount(3);
foreach ($sps as $sp) {
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
expect($sp->current_limit)->toBe(9);
}
});
it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_projects + 3 pivot links', function (): void {
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'allrf.example.com',
'is_active' => true,
'daily_limit_target' => 6,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '500', 'src' => 'rt', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
['id' => '501', 'src' => 'bl', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
['id' => '502', 'src' => 'mt', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$sps = SupplierProject::where('unique_key', 'allrf.example.com')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
// ---------------------------------------------------------------------------
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
// ---------------------------------------------------------------------------
it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, no project_supplier_links pivot)', function (): void {
// batch is already set in beforeEach — no change needed
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'batch-test.ru',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [82],
'delivery_days_mask' => 127,
]);
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldReceive('createProject')->times(3)->andReturn(200001, 200002, 200003);
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$project->refresh();
// Batch: the old FK columns are set
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
// Batch: каркас → limit=0
$sp = SupplierProject::find($project->supplier_b1_project_id);
expect($sp->current_limit)->toBe(0);
// Batch: no pivot rows (nightly job fills them)
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
});
@@ -12,10 +12,10 @@ use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
@@ -41,279 +41,346 @@ afterEach(function (): void {
Carbon::setTestNow();
});
test('creates supplier_project at supplier when supplier_external_id is null', function (): void {
// ---------------------------------------------------------------------------
// Multi-region grouping (merged into single group)
// ---------------------------------------------------------------------------
/**
* Project regions=[82,83] site 1 group (merged regions) tag='РФ'
* 1 multi-flag save 3 supplier_projects (platforms B1/B2/B3)
* subject_code=null, current_regions=[82,83]; pivot 3 links for the project.
*/
test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 supplier_projects + 3 pivot links', function (): void {
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'create-flow.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
Project::factory()->create([
/** @var Project $project */
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'create-flow.example.com',
'supplier_b1_project_id' => $sp->id,
'signal_identifier' => 'persubject.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
'regions' => [82, 83],
]);
// One save (merged regions=[82,83] → tag='РФ') + one listProjects
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sp->refresh();
expect($sp->supplier_external_id)->toBe('555')
->and($sp->sync_status)->toBe('ok')
->and($sp->current_limit)->toBe(3);
// 3 supplier_projects (not 6): all regions merged into one group
$sps = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'persubject.example.com')
->where('signal_type', 'site')
->get();
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save'));
expect($sps)->toHaveCount(3);
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
// subject_code=null (no per-subject split)
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
// regions merged: [82, 83] — sorted ascending, stored on each SP
expect($sps->firstWhere('platform', 'B1')->current_regions)->toBe([82, 83]);
// pivot: 3 links (not 6)
$pivotCount = DB::table('project_supplier_links')
->where('project_id', $project->id)
->count();
expect($pivotCount)->toBe(3);
});
test('updates when diff detected', function (): void {
// ---------------------------------------------------------------------------
// All-RF pool
// ---------------------------------------------------------------------------
test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void {
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'update-flow.example.com',
'supplier_external_id' => '12345',
'current_limit' => 1,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
]);
Project::factory()->create([
/** @var Project $project */
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'update-flow.example.com',
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 30,
'signal_identifier' => 'rf-pool.example.com',
'daily_limit_target' => 6,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '500', 'src' => 'rt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sp->refresh();
expect($sp->current_limit)->toBe(10)
->and($sp->sync_status)->toBe('ok');
$sps = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'rf-pool.example.com')
->where('signal_type', 'site')
->get();
// Update теперь идёт на тот же endpoint что и save (verified 2026-05-19 — Task 1 recon),
// с id:N в body вместо id:0.
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && $r['id'] === 12345);
expect($sps)->toHaveCount(3);
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
expect($sps->pluck('current_regions')->first())->toBe([]);
// pivot
$pivotCount = DB::table('project_supplier_links')
->where('project_id', $project->id)
->count();
expect($pivotCount)->toBe(3);
});
test('skips when no diff between current and computed allocation', function (): void {
// ---------------------------------------------------------------------------
// Order: 2 projects on one (source × subject) → computeOrder
// ---------------------------------------------------------------------------
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'no-diff.example.com',
'supplier_external_id' => '999',
'current_limit' => 9,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
'sync_status' => 'ok',
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'no-diff.example.com',
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 27,
'signal_identifier' => 'order-test.example.com',
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
Http::fake();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Http::assertNothingSent();
});
test('isolates failure: one bad supplier_project does not stop others', function (): void {
$tenant = Tenant::factory()->create();
$bad = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'bad.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
$good = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'good.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
'regions' => [],
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'bad.example.com',
'supplier_b1_project_id' => $bad->id,
'daily_limit_target' => 9,
'signal_identifier' => 'order-test.example.com',
'daily_limit_target' => 20,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'good.example.com',
'supplier_b2_project_id' => $good->id,
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save')
->push('bad request', 422)
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
expect(
SupplierSyncLog::on('pgsql_supplier')
->where('supplier_project_id', $bad->id)
->whereNotNull('error_message')
->exists()
)->toBeTrue();
expect($good->fresh()->supplier_external_id)->toBe('777');
});
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
Mail::fake();
$tenant = Tenant::factory()->create();
for ($i = 1; $i <= 60; $i++) {
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => "host{$i}.example.com",
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => "host{$i}.example.com",
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
]);
}
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
return $mail->alertType === 'mass_transient';
});
});
test('writes supplier_sync_log row for each successful action', function (): void {
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'audit-log.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'audit-log.example.com',
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
'regions' => [],
]);
// saveProjectMultiFlag called once (both projects share same group)
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$log = SupplierSyncLog::on('pgsql_supplier')
->where('supplier_project_id', $sp->id)
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
$sp = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'order-test.example.com')
->where('platform', 'B1')
->first();
expect($log)->not->toBeNull()
->and($log->action)->toBe('create')
->and($log->http_status)->toBe(200)
->and($log->error_message)->toBeNull();
expect($sp)->not->toBeNull();
expect($sp->current_limit)->toBe(20);
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
expect(SupplierProject::on('pgsql_supplier')
->where('unique_key', 'order-test.example.com')
->count())->toBe(3);
});
// ---------------------------------------------------------------------------
// SMS platforms
// ---------------------------------------------------------------------------
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['79001234567'],
'sms_keyword' => 'KVARTIRA',
'daily_limit_target' => 5,
'delivery_days_mask' => 127,
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sps = SupplierProject::on('pgsql_supplier')
->where('signal_type', 'sms')
->get();
// sms+keyword → B2+B3 only
expect($sps)->toHaveCount(2);
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
expect($sps->where('platform', 'B1')->count())->toBe(0);
});
test('sms without keyword → platform B3 only (1 supplier_project)', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['79009876543'],
'sms_keyword' => null,
'daily_limit_target' => 5,
'delivery_days_mask' => 127,
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sps = SupplierProject::on('pgsql_supplier')
->where('signal_type', 'sms')
->get();
expect($sps)->toHaveCount(1);
expect($sps->first()->platform)->toBe('B3');
});
// ---------------------------------------------------------------------------
// Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot)
// ---------------------------------------------------------------------------
test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'idempotent.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
// First run: create
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
expect(SupplierProject::on('pgsql_supplier')
->where('unique_key', 'idempotent.example.com')
->count())->toBe(3);
// Second run: no changes → updateProject calls (rt-project-save with id != 0)
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Still 3 (no duplicates)
expect(SupplierProject::on('pgsql_supplier')
->where('unique_key', 'idempotent.example.com')
->count())->toBe(3);
// updateProject sends id != 0
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save')
&& (int) ($r['id'] ?? 0) !== 0);
});
// ---------------------------------------------------------------------------
// Orthogonal: time budget, auth, abort-50, sync_log
// ---------------------------------------------------------------------------
test('respects time budget by stopping at 20:55 МСК', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'time-budget.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'time-budget.example.com',
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
'regions' => [],
]);
Http::fake();
@@ -322,60 +389,20 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
Http::assertNothingSent();
});
test('passes regions directly to allocator without bitmask conversion', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'regions' => [82, 83],
'region_mask' => 255,
]);
$job = new SyncSupplierProjectsJob;
$projects = Project::where('tenant_id', $tenant->id)->get();
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
expect($adapted->first()->regions)->toBe([82, 83]);
});
test('passes empty array to allocator when project has regions=[]', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'regions' => [],
'region_mask' => 255,
]);
$job = new SyncSupplierProjectsJob;
$projects = Project::where('tenant_id', $tenant->id)->get();
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
expect($adapted->first()->regions)->toBe([]);
});
test('sticky auth error throws and sends critical alert email', function (): void {
Mail::fake();
Bus::fake([RefreshSupplierSessionJob::class]);
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'auth-fail.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'auth-fail.example.com',
'supplier_b1_project_id' => $sp->id,
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'region_mask' => 255,
'region_mode' => 'include',
'regions' => [],
]);
Http::fake([
@@ -390,40 +417,77 @@ test('sticky auth error throws and sends critical alert email', function (): voi
});
});
test('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void {
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
Mail::fake();
$tenant = Tenant::factory()->create();
$sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'regions-flow.example.com',
'supplier_external_id' => null,
'current_limit' => 0,
'current_workdays' => [],
'current_regions' => [],
]);
for ($i = 1; $i <= 60; $i++) {
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => "host{$i}.abort.com",
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
}
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
return $mail->alertType === 'mass_transient';
});
});
test('writes supplier_sync_log row for each successful action', function (): void {
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'regions-flow.example.com',
'supplier_b1_project_id' => $sp->id,
'signal_identifier' => 'audit-log.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [82, 83],
'region_mask' => 255,
'region_mode' => 'include',
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'],
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sp->refresh();
expect($sp->current_regions)->toBe([82, 83])
->and($sp->supplier_external_id)->toBe('556');
// 3 supplier_projects created → 3 log rows (one per platform)
$sp = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'audit-log.example.com')
->where('platform', 'B1')
->first();
expect($sp)->not->toBeNull();
$log = SupplierSyncLog::on('pgsql_supplier')
->where('supplier_project_id', $sp->id)
->first();
expect($log)->not->toBeNull()
->and($log->action)->toBe('create')
->and($log->http_status)->toBe(200)
->and($log->error_message)->toBeNull();
});
+9 -8
View File
@@ -9,9 +9,10 @@ import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
// AdminLayout содержит:
// - sidebar #012019 с brand-block «Лидерра.» + ADMIN метка + 7 nav-items
// (Тенанты 142 / Биллинг / Тарифная сетка / Цены поставщиков / Инциденты 3 /
// Impersonation / Система);
// - sidebar #012019 с brand-block «Лидерра.» + ADMIN метка + 9 nav-items
// (Тенанты / Биллинг / Тарифная сетка / Цены поставщиков / Инциденты /
// Impersonation / Система / Интеграция с поставщиком / Проекты у поставщика),
// без mock count-badge;
// - topbar с breadcrumb («Админка <currentPageTitle>») + user-menu;
// - <v-main> RouterView; DevIndexBadge.
@@ -84,12 +85,12 @@ describe('AdminLayout.vue', () => {
);
});
it('показывает count-badge для Тенантов (142) и Инцидентов (3) и не для остальных', async () => {
it('не рендерит захардкоженные mock count-badge (live-счётчики — отдельная фича)', async () => {
// Ранее в nav были mock-счётчики Тенанты=142 / Инциденты=3, расходящиеся с реальными
// данными (5 тенантов / 0 открытых инцидентов). Удалены — неверный бейдж хуже отсутствия.
const { wrapper } = await mountAdminLayout();
const counts = wrapper.findAll('.nav-count').map((n) => n.text());
expect(counts).toContain('142');
expect(counts).toContain('3');
expect(counts).toHaveLength(2);
const counts = wrapper.findAll('.nav-count');
expect(counts).toHaveLength(0);
});
it('breadcrumb на /admin/tenants показывает «Тенанты»', async () => {
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
vi.mock('axios');
const vuetify = createVuetify({ components, directives });
describe('AdminSupplierIntegrationView — export-mode toggle (Plan 4 Task 1)', () => {
beforeEach(() => {
vi.clearAllMocks();
(axios.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.endsWith('/export-mode')) {
return Promise.resolve({ data: { mode: 'batch' } });
}
if (url.endsWith('/manual-queue')) {
return Promise.resolve({ data: { queue: [] } });
}
return Promise.resolve({ data: { health: null, history: [] } });
});
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { mode: 'online' } });
});
it('GETs current mode on mount and renders the toggle with current label', async () => {
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration/export-mode');
const toggle = wrapper.find('[data-testid="export-mode-toggle"]');
expect(toggle.exists()).toBe(true);
expect(wrapper.text()).toContain('Режим экспорта проектов');
expect(wrapper.text()).toContain('Пакетный');
});
it('switching to online POSTs the new value', async () => {
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const onlineBtn = wrapper.find('[data-testid="export-mode-online"]');
expect(onlineBtn.exists()).toBe(true);
await onlineBtn.trigger('click');
await new Promise((r) => setTimeout(r, 20));
expect(axios.post).toHaveBeenCalledWith(
'/api/admin/supplier-integration/export-mode',
{ mode: 'online' },
);
});
});
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminSupplierProjectsView from '../../resources/js/views/admin/AdminSupplierProjectsView.vue';
vi.mock('axios');
const vuetify = createVuetify({ components, directives });
// VDialog телепортит контент в body → стаб рендерит слот инлайн (квирк: VDialog
// teleport стаб для поиска confirm-кнопки внутри диалога).
const mountView = () =>
mount(AdminSupplierProjectsView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
describe('AdminSupplierProjectsView (Plan 4 Task 3)', () => {
beforeEach(() => {
vi.clearAllMocks();
(axios.get as ReturnType<typeof vi.fn>).mockResolvedValue({
data: {
projects: [
{
id: 1,
platform: 'B1',
signal_type: 'site',
unique_key: 'okna.ru',
subject_code: 82,
subject_name: 'Москва',
current_limit: 5,
supplier_external_id: '777',
orderers: ['ООО Ромашка'],
last_delivery_at: '2026-05-19T10:00:00Z',
},
],
},
});
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { deleted: 1, failures: [] },
});
});
it('GETs list on mount and renders rows (source, region, orderers)', async () => {
const wrapper = mountView();
await flushPromises();
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration/projects');
const text = wrapper.text();
expect(text).toContain('okna.ru');
expect(text).toContain('Москва');
expect(text).toContain('ООО Ромашка');
});
it('bulk-deletes selected rows after confirm', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="row-checkbox-1"] input').setValue(true);
await wrapper.find('[data-testid="bulk-delete-btn"]').trigger('click');
await flushPromises();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(axios.post).toHaveBeenCalledWith(
'/api/admin/supplier-integration/projects/delete',
{ ids: [1] },
);
});
it('bulk-delete button is disabled when nothing selected', async () => {
const wrapper = mountView();
await flushPromises();
const btn = wrapper.find('[data-testid="bulk-delete-btn"]');
expect(btn.attributes('disabled')).toBeDefined();
});
});
+19
View File
@@ -59,6 +59,13 @@ const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockU
{ path: '/billing', component: { template: '<div>billing</div>' } },
{ path: '/reports', component: { template: '<div>reports</div>' } },
{ path: '/settings', component: { template: '<div>settings</div>' } },
// Не в sidebar nav, но имеют meta.title — topbar должен брать title оттуда.
{
path: '/reminders',
component: { template: '<div>reminders</div>' },
meta: { title: 'Напоминания' },
},
{ path: '/import', component: { template: '<div>import</div>' }, meta: { title: 'Импорт данных' } },
],
});
await router.push(path);
@@ -110,6 +117,18 @@ describe('AppLayout.vue', () => {
expect(wrapper.text()).toContain('Дашборд');
});
it('topbar title для страницы вне sidebar nav берётся из route.meta.title (Напоминания)', async () => {
const wrapper = await mountAppLayout('/reminders');
// Напоминания нет в sidebar nav (см. тест выше) — title должен прийти из meta, не «Страница».
expect(wrapper.text()).toContain('Напоминания');
expect(wrapper.text()).not.toContain('Страница');
});
it('topbar title для /import берётся из route.meta.title (Импорт данных)', async () => {
const wrapper = await mountAppLayout('/import');
expect(wrapper.text()).toContain('Импорт данных');
});
it('user-chip показывает initials и shortName из store user', async () => {
const wrapper = await mountAppLayout();
const text = wrapper.text();
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import DashboardPageHead from '../../resources/js/components/dashboard/DashboardPageHead.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
const mockUser: AuthUser = {
id: 1,
email: 'petr.sidorov@example.ru',
first_name: 'Пётр',
last_name: 'Сидоров',
tenant_id: 1,
totp_enabled: false,
last_login_at: null,
};
const mountHead = (user: AuthUser | null = mockUser) => {
setActivePinia(createPinia());
useAuthStore().user = user;
return mount(DashboardPageHead, {
props: { modelValue: 'today' },
global: { plugins: [createVuetify()] },
});
};
describe('DashboardPageHead.vue', () => {
it('приветствие использует имя залогиненного пользователя, не захардкоженное «Иван»', () => {
const wrapper = mountHead();
const greet = wrapper.find('.page-greet').text();
expect(greet).toContain('Пётр');
expect(greet).not.toContain('Иван');
});
it('при отсутствии user приветствие рендерится без падения', () => {
const wrapper = mountHead(null);
expect(wrapper.find('.page-greet').exists()).toBe(true);
expect(wrapper.find('.page-greet').text().length).toBeGreaterThan(0);
});
});
+3 -3
View File
@@ -8,9 +8,9 @@ import type { LeadStatus } from '../../resources/js/composables/leadStatuses';
const vuetify = createVuetify();
const statuses: LeadStatus[] = [
{ slug: 'new', nameRu: 'Новая сделка', colorHex: '#5b2db2', order: 1 } as LeadStatus,
{ slug: 'viewed', nameRu: 'Просмотрено', colorHex: '#5a2db2', order: 2 } as LeadStatus,
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C', order: 3 } as LeadStatus,
{ slug: 'new', nameRu: 'Новая сделка', isSystem: true, sortOrder: 1, colorHex: '#5b2db2' },
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#5a2db2' },
{ slug: 'won', nameRu: 'Куплено', isSystem: true, sortOrder: 3, colorHex: '#00A36C' },
];
function makeDeal(over: Partial<MockDeal> = {}): MockDeal {
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
vi.mock('axios');
vi.mock('../../resources/js/api/client', () => ({
apiClient: {
post: vi.fn().mockResolvedValue({ data: {} }),
patch: vi.fn().mockResolvedValue({ data: {} }),
},
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
}));
import { apiClient } from '../../resources/js/api/client';
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
// VDialog teleport-стаб (как в NewProjectDialog.spec.ts): рендерит слот инлайн.
const factory = () =>
mount(NewProjectDialog, {
props: { modelValue: true, mode: 'create' as const },
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe('NewProjectDialog — required region gate + «Вся РФ» (Plan 4 Task 4)', () => {
it('blocks submit when no region chosen and shows error', async () => {
const w = factory();
await flushPromises();
await w.find('[data-testid="submit-btn"]').trigger('click');
await flushPromises();
expect(apiClient.post).not.toHaveBeenCalled();
expect(w.text()).toContain('Выберите регион');
});
it('«Вся РФ» shows warning, requires confirm, then submits regions=[]', async () => {
const w = factory();
await flushPromises();
(w.vm as unknown as { chooseVsyaRf: () => void }).chooseVsyaRf();
await w.vm.$nextTick();
expect(w.text()).toContain('всю Россию');
await w.find('[data-testid="confirm-vsya-rf"]').trigger('click');
await w.vm.$nextTick();
await w.find('[data-testid="submit-btn"]').trigger('click');
await flushPromises();
expect(apiClient.post).toHaveBeenCalledTimes(1);
const payload = (apiClient.post as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][1] as {
regions: number[];
};
expect(payload.regions).toEqual([]);
});
it('picking subjects after «Вся РФ» clears the confirmation (mutual exclusion)', async () => {
const w = factory();
await flushPromises();
const vm = w.vm as unknown as {
chooseVsyaRf: () => void;
confirmVsyaRf: () => void;
onRegionsChange: (codes: number[]) => void;
vsyaRfConfirmed: boolean;
};
vm.chooseVsyaRf();
vm.confirmVsyaRf();
await w.vm.$nextTick();
expect(vm.vsyaRfConfirmed).toBe(true);
vm.onRegionsChange([77]);
await w.vm.$nextTick();
expect(vm.vsyaRfConfirmed).toBe(false);
await w.find('[data-testid="submit-btn"]').trigger('click');
await flushPromises();
const payload = (apiClient.post as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][1] as {
regions: number[];
};
expect(payload.regions).toEqual([77]);
});
});
+12 -3
View File
@@ -168,12 +168,21 @@ describe('api/deals', () => {
expect(r).toHaveLength(1);
});
it('listProjects() GET /api/projects + unwraps data.projects', async () => {
it('listProjects() GET /api/projects + unwraps { data: [...] } (JsonResource collection)', async () => {
// ProjectController::index() отдаёт response()->json(['data' => ProjectResource::collection(...)]).
vi.mocked(apiClient.get).mockResolvedValue({
data: { projects: [{ id: 1, name: 'P', tag: 'site', type: 'webhook' }] },
data: { data: [{ id: 1, name: 'B1_Окна СПб' }, { id: 2, name: 'B2_Двери' }] },
});
const r = await listProjects(1);
expect(apiClient.get).toHaveBeenCalledWith('/api/projects', { params: { tenant_id: 1 } });
expect(r[0].name).toBe('P');
expect(Array.isArray(r)).toBe(true);
expect(r).toHaveLength(2);
expect(r[0].name).toBe('B1_Окна СПб');
});
it('listProjects() возвращает [] при ответе без массива (защита от undefined.map)', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
const r = await listProjects(1);
expect(r).toEqual([]);
});
});
+23
View File
@@ -1,6 +1,9 @@
<?php
use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/*
@@ -50,3 +53,23 @@ function something()
{
// ..
}
/**
* Link a Лидерра-project to a supplier_project via the M:N pivot
* (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot
* only; legacy supplier_b{1,2,3}_project_id FK is ignored for routing.
*
* Single source replaces previous duplicated declarations in
* LeadRouterTest.php / RouteSupplierLeadJobTest.php (Plan 2 cleanup).
* pivot created_at has DEFAULT NOW(); supplier->subject_code may be null.
*/
function linkProjectToSupplier(Project $project, SupplierProject $supplier): void
{
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
'subject_code' => $supplier->subject_code,
]);
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Services\LeadDistributor;
use Illuminate\Support\Collection;
use Random\Engine\Mt19937;
use Random\Randomizer;
function projects(int $n): Collection
{
return collect(range(1, $n))->map(fn (int $i) => (object) ['id' => $i]);
}
it('returns all when eligible count <= cap (3)', function (): void {
$d = new LeadDistributor;
expect($d->selectRecipients(projects(2)))->toHaveCount(2)
->and($d->selectRecipients(projects(3)))->toHaveCount(3);
});
it('caps at 3 when more eligible', function (): void {
$d = new LeadDistributor;
expect($d->selectRecipients(projects(7)))->toHaveCount(3);
});
it('selection is a subset of eligible and deterministic under seeded RNG', function (): void {
$eligible = projects(7);
$d = new LeadDistributor(new Randomizer(new Mt19937(42)));
$picked = $d->selectRecipients($eligible)->pluck('id')->all();
expect($picked)->toHaveCount(3)
->and(collect($picked)->every(fn ($id) => $id >= 1 && $id <= 7))->toBeTrue();
// тот же seed → тот же выбор
$d2 = new LeadDistributor(new Randomizer(new Mt19937(42)));
expect($d2->selectRecipients($eligible)->pluck('id')->all())->toBe($picked);
});
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use App\Services\RegionTagResolver;
use App\Support\RussianRegions;
it('resolves subject name to code', function (): void {
$r = new RegionTagResolver;
expect($r->resolve('Москва'))->toBe(82)
->and($r->resolve('Санкт-Петербург'))->toBe(83)
->and($r->resolve('Республика Адыгея'))->toBe(1);
});
it('returns null for «РФ» pool tag, empty and unknown', function (): void {
$r = new RegionTagResolver;
expect($r->resolve('РФ'))->toBeNull()
->and($r->resolve(''))->toBeNull()
->and($r->resolve('Нарния'))->toBeNull();
});
it('canonical region map mirrors regions.ts — exactly 89 subjects', function (): void {
expect(count(RussianRegions::CODE_TO_NAME))->toBe(89);
});
@@ -224,3 +224,65 @@ test('RefreshSupplierSessionJob throws during initial loadSession translated to
->and($caught->getPrevious())->toBeInstanceOf(RuntimeException::class)
->and($caught->getPrevious()?->getMessage())->toBe('Simulated Playwright crash during loadSession');
});
test('200 HTML login page triggers RefreshSupplierSessionJob sync and retries once', function (): void {
Bus::fake([RefreshSupplierSessionJob::class]);
Http::fakeSequence('crm.bp-gr.ru/*')
->push(
'<html><body><form action="/login"><input id="loginform-username" name="LoginForm[username]"></form></body></html>',
200,
['Content-Type' => 'text/html; charset=utf-8'],
)
->push('{"projects":[]}', 200, ['Content-Type' => 'application/json']);
app(SupplierPortalClient::class)->listProjects();
Bus::assertDispatchedSync(RefreshSupplierSessionJob::class);
Http::assertSentCount(2);
});
test('sticky HTML login page after retry throws SupplierAuthException', function (): void {
Bus::fake([RefreshSupplierSessionJob::class]);
Http::fakeSequence('crm.bp-gr.ru/*')
->push(
'<html><input id="loginform-username"></html>',
200,
['Content-Type' => 'text/html; charset=utf-8'],
)
->push(
'<html><input id="loginform-username"></html>',
200,
['Content-Type' => 'text/html; charset=utf-8'],
);
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierAuthException::class, 'Portal returned login page after refresh');
});
test('JSON response with substring "loginform-username" is NOT misclassified as login page', function (): void {
Http::fake([
'crm.bp-gr.ru/*' => Http::response(
'{"projects":[{"name":"loginform-username is just a string here"}]}',
200,
['Content-Type' => 'application/json'],
),
]);
$result = app(SupplierPortalClient::class)->listProjects();
expect($result)->toHaveCount(1);
Http::assertSentCount(1); // no retry — JSON header skips login-detect
});
test('200 response without Content-Type header is NOT detected as login page', function (): void {
// Документирует контракт: пустой Content-Type → str_starts_with('','text/html') === false → детект пропускается.
Http::fake([
'crm.bp-gr.ru/*' => Http::response('{"projects":[]}', 200), // no Content-Type header
]);
app(SupplierPortalClient::class)->listProjects();
Http::assertSentCount(1); // no retry — empty Content-Type fails the text/html gate
});
@@ -9,156 +9,53 @@ use Tests\TestCase;
uses(TestCase::class);
// 2026-05-12 — это вторник (isoWeekday=2 в Europe/Moscow).
// 2026-05-16суббота (isoWeekday=6), 2026-05-17 — воскресенье (isoWeekday=7).
// 2026-05-12 — вторник (isoWeekday=2 Europe/Moscow).
// 2026-05-13среда (isoWeekday=3).
test('site signal distributes B1 ceil(total/3), B2 ceil(remainder/2), B3 remainder', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 10, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (array $limits, int $expected): void {
expect(SupplierQuotaAllocator::computeOrder($limits))->toBe($expected);
})->with([
'brief 1' => [[5, 5, 10, 20], 20],
'brief 2' => [array_merge(array_fill(0, 15, 5), [10]), 29], // 15×5+10 → Σ85, наиб10, ceil(85/3)=29
'brief 3' => [[15, 15, 15], 15],
'brief 4' => [[15, 15, 15, 30], 30],
'brief 5' => [[10, 10, 10, 10], 14],
'single' => [[7], 7],
'empty' => [[], 0],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
// Orthogonal smoke tests on allocate() — preserved from pre-T3 coverage; assert
// invariants independent of the order formula (workdays/regions union, null-on-no-eligible).
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit + $b2->limit + $b3->limit)->toBe(10)
->and($b1->limit)->toBe(4)
->and($b2->limit)->toBe(3)
->and($b3->limit)->toBe(3);
});
test('call signal same distribution as site (B1/B2/B3 split)', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 30, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'call', '79991234567', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b1->limit)->toBe(10);
});
test('sms with keyword distributes B2+B3 only (B1 returns 0)', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 4, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit)->toBe(0)
->and($b2->limit)->toBe(2)
->and($b3->limit)->toBe(2);
});
test('returns null when no active liderra projects on target weekday', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 10, 'workdays' => [6, 7], 'regions' => []],
]);
$allocation = SupplierQuotaAllocator::allocate(
'B1',
'site',
'example.com',
$projects,
Carbon::parse('2026-05-12'),
);
expect($allocation)->toBeNull();
});
test('workdays union deduplicates and sorts', function (): void {
// Targeting Wednesday (2026-05-13, isoWeekday=3): оба проекта содержат 3 → оба eligible,
// союз их workdays — [1,2,3,4,5].
it('workdays union deduplicates and sorts', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3], 'regions' => []],
(object) ['daily_limit' => 5, 'workdays' => [3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-13'));
$dto = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-13'));
expect($b1)->not->toBeNull()
->and($b1->workdays)->toBe([1, 2, 3, 4, 5]);
expect($dto)->not->toBeNull()
->and($dto->workdays)->toBe([1, 2, 3, 4, 5]);
});
test('regions union deduplicates and sorts', function (): void {
it('regions union deduplicates and sorts', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => [77, 50]],
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => [50, 78]],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$dto = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b1->regions)->toBe([50, 77, 78]);
expect($dto)->not->toBeNull()
->and($dto->regions)->toBe([50, 77, 78]);
});
test('empty regions stays empty (all regions semantics)', function (): void {
it('returns null when no active liderra projects on target weekday', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
(object) ['daily_limit' => 10, 'workdays' => [6, 7], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b1->regions)->toBe([]);
});
test('single project with limit=1 sites to B1 only', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 1, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit)->toBe(1)
->and($b2->limit)->toBe(0)
->and($b3->limit)->toBe(0);
});
test('large scale: 1000 projects with limit 10 each = 10000 total', function (): void {
$projects = new Collection(array_fill(0, 1000, (object) [
'daily_limit' => 10,
'workdays' => [1, 2, 3, 4, 5],
'regions' => [],
]));
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit + $b2->limit + $b3->limit)->toBe(10000)
->and($b1->limit)->toBe(3334);
});
test('odd total: 7 distributes B1=3, B2=2, B3=2', function (): void {
$projects = new Collection([
(object) ['daily_limit' => 7, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
]);
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
expect($b1)->not->toBeNull()
->and($b2)->not->toBeNull()
->and($b3)->not->toBeNull()
->and($b1->limit)->toBe(3)
->and($b2->limit)->toBe(2)
->and($b3->limit)->toBe(2);
expect(SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12')))
->toBeNull();
});
+85 -1
View File
@@ -1475,7 +1475,6 @@ DWC
инжектим
фикстурный
роута
# Brain dashboard design spec (2026-05-19)
визуализирующий
анимируются
@@ -1488,3 +1487,88 @@ DWC
visualises
AGD
agg
# Supplier migration follow-up (2026-05-19)
ретрая
детекта
Регэксп
фрэш
дебагом
srcrt
srcbl
srcmt
симв
# finance-tooling C6+C7 epic — design spec (2026-05-20)
GAAP
РСБУ
вендоренного
джоб
линтуется
парсятся
ретрай
субледжер
хардкодит
# finance-tooling C6+C7 — billing-audit skill (2026-05-20)
TOCTOU
bcadd
bcsub
bcmul
# finance-tooling C6+C7 — ADR-012 (2026-05-20)
непусты
# Project migration redesign — plan 1 foundation (2026-05-20)
сид
бэкофилл
бэкофилла
psl
# Project migration redesign — plan 2 distribution (2026-05-20)
инъектируемый
сидируемый
проде
бэкофиллом
# Project migration redesign — plan 3 export (2026-05-20)
диспатчит
rsave
# Project migration redesign — plan 4 admin + ЛК (2026-05-20)
vsya
# Каналы миграции / проверка 20.05.2026
стэшей
учёток
залогиненному
незакоммичено
# Workdays/resync supplier sync fix (2026-05-20)
Незакоммиченного
petr
mariya
хардкодил
Ресинк
# A1 backend-tooling integration (2026-05-20)
driftingly
nunomaduro
lemed
евалы
дебага
трейс
трейсбэки
антропик
реюз
опц
спекам
джобе
биллингового
непуст
гейтят
гейты
# Сквозной чек-лист портала + 6 фиксов (2026-05-21)
захардкоженным
смердженных
+39 -1
View File
@@ -2,10 +2,48 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать четыре записи в обратном хронологическом порядке (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.25, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.26, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)
`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
+1 CHECK `chk_supplier_projects_subject_code`. Unique-индекс
`supplier_projects_platform_unique_key_unique` (platform, unique_key) → заменён на
`supplier_projects_platform_key_subject_unique` (platform, unique_key, subject_code)
NULLS NOT DISTINCT (пул «Вся РФ» уникален per источник×платформа).
Эпик: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.2.
Миграция: 2026_05_20_100000_supplier_projects_subject_code.php (Schema::hasColumn +
pg_constraint guards). Индексы: −1 +1 (нет дельты count). RLS не затронут (SaaS-level).
## v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)
+1 таблица SaaS-level `project_supplier_links` (project_id, supplier_project_id,
platform, subject_code, created_at): M:N замена 3 FK-слотов
projects.supplier_b{1,2,3}_project_id (per-субъект модель). +2 FK (оба ON DELETE
CASCADE), +1 CHECK chk_psl_platform, +1 UNIQUE uq_psl_project_supplier, +2 индекса.
Без RLS (как supplier_projects). Старые FK-колонки остаются (двойная запись) до
follow-up. Миграция: 2026_05_20_101000_create_project_supplier_links.php.
## v8.26 (доп) — 2026-05-20 — deals.subject_code
`deals` +1 колонка `subject_code SMALLINT NULL` — субъект РФ из тега поставщика
(raw_payload[tag]); отдельно от region_code (ISO, phone-derived). Наследуется
12 партициями. Миграция: 2026_05_20_102000_deals_subject_code.php.
## v8.26 (доп) — 2026-05-20 — seed system_settings.supplier_export_mode
Сид-строка `supplier_export_mode='batch'` (тумблер режима экспорта; online|batch).
Не структурное изменение. Миграция: 2026_05_20_103000_seed_supplier_export_mode.php.
## v8.26 (доп) — 2026-05-20 — deals.subject_code range CHECK (defensive parity)
+1 CHECK `chk_deals_subject_code` на партиционированной `deals` (subject_code IS NULL OR
BETWEEN 1 AND 89). Закрывает review-finding Plan 1 — defensive parity с
`chk_supplier_projects_subject_code` (malformed tag → silent garbage). NOT VALID + VALIDATE
(squawk-safe). Миграция: 2026_05_20_105000_deals_subject_code_check.php.
## v8.25 — 2026-05-19 — supplier_manual_sync_queue (Tier 3 резерва канала миграции проектов)
**+1 таблица** SaaS-level (без tenant_id / RLS, как `supplier_csv_reconcile_log`):
+28 -6
View File
@@ -1,7 +1,8 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 121 индекс / 40 RLS-политик / 5 функций / 13 триггеров
-- Версия: v8.26 (20.05.2026 — project-migration-redesign Plans 1-3: supplier_projects.subject_code (per-субъект экспорт) + project_supplier_links (M:N pivot projects↔supplier_projects) + deals.subject_code + CHECK chk_deals_subject_code + seed system_settings.supplier_export_mode)
-- Метрики: 65 базовые таблицы (63 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 123 индекса / 40 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
@@ -909,6 +910,7 @@ CREATE TABLE supplier_projects (
CHECK (current_limit >= 0),
current_workdays JSONB, -- объединение workdays активных tenant'ов
current_regions JSONB, -- объединение regions активных tenant'ов
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26)
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
last_synced_at TIMESTAMPTZ,
inactive_since TIMESTAMPTZ, -- момент когда последний tenant отвалился (TTL 180 дней)
@@ -923,11 +925,13 @@ CREATE TABLE supplier_projects (
CHECK (sync_status IN ('pending','ok','failed')),
-- B1 не поддерживает СМС (см. spec §2.2 — таблица platform×signal_type)
CONSTRAINT chk_supplier_projects_b1_not_for_sms
CHECK (NOT (platform = 'B1' AND signal_type = 'sms'))
CHECK (NOT (platform = 'B1' AND signal_type = 'sms')),
CONSTRAINT chk_supplier_projects_subject_code
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
);
CREATE UNIQUE INDEX supplier_projects_platform_unique_key_unique
ON supplier_projects(platform, unique_key);
CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique
ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT;
CREATE INDEX supplier_projects_sync_status_index
ON supplier_projects(sync_status);
CREATE INDEX supplier_projects_inactive_since_index
@@ -950,6 +954,20 @@ CREATE INDEX idx_projects_supplier_b1_project_id ON projects(supplier_b1_project
CREATE INDEX idx_projects_supplier_b2_project_id ON projects(supplier_b2_project_id) WHERE supplier_b2_project_id IS NOT NULL;
CREATE INDEX idx_projects_supplier_b3_project_id ON projects(supplier_b3_project_id) WHERE supplier_b3_project_id IS NOT NULL;
-- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id).
CREATE TABLE project_supplier_links (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
platform VARCHAR(4) NOT NULL,
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
);
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
CREATE INDEX idx_psl_project ON project_supplier_links(project_id);
-- v8.17 (Plan 1/5 Task 2 fix): defense-in-depth — Project-уровень тоже запрещает B1+SMS.
-- supplier_projects уже имеет CHECK chk_supplier_projects_b1_not_for_sms; здесь дублируем
-- на projects, чтобы исключить логическую несостыковку «sms-проект привязан к B1-supplier».
@@ -1610,6 +1628,7 @@ CREATE TABLE deals (
-- удалось определить. city — свободный текст (приходит из webhook или
-- enrichment-сервиса). Используется для filter в §10.3 + аналитики §12.
region_code VARCHAR(8),
subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно
city VARCHAR(100),
-- v8.5 (Биз-22): простая lead scoring модель без ML.
-- time_in_form_seconds — сколько секунд физлицо заполняло форму
@@ -1633,7 +1652,10 @@ CREATE TABLE deals (
CONSTRAINT chk_deals_lead_score_range
CHECK (lead_score IS NULL OR (lead_score >= 0.00 AND lead_score <= 99.99)),
CONSTRAINT chk_deals_escalated_count_nonneg
CHECK (escalated_count >= 0)
CHECK (escalated_count >= 0),
-- v8.26: subject_code диапазон субъекта РФ 1..89 (defensive parity с supplier_projects).
CONSTRAINT chk_deals_subject_code
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
) PARTITION BY RANGE (received_at);
-- Индексы на родительской таблице наследуются партициями
+11 -2
View File
@@ -1,8 +1,12 @@
# Plugin Stack Rules — Superpowers + Frontend Design (v3.17)
# Plugin Stack Rules — Superpowers + Frontend Design (v3.19)
**Дата:** 19.05.2026
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
**v3.19** — A1 backend-tooling: R10.1 Блок 1 note +backend-tooling (#64 Rector + #65 PHP Insights — Composer dev-deps; #66 laravel-backend-patterns — self-authored project-скил; #67 NightOwl — DEFERRED, MCP при активации). Новая 16-я off-phase подкатегория backend-tooling, раздел A1 карты. R15.6 +backend-tooling в список категорий. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.19, Pravila v1.35, CLAUDE.md v2.22, ADR-013; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
**v3.18** — finance-tooling (C6+C7): R10.1 Блок 1 +finance plugin (#61, marketplace `finance@knowledge-work-plugins`, homed C7, cross-ref C6) + note (+billing-audit #62 / ru-tax-accounting #63 — self-authored project-скилы). Новая 15-я off-phase подкатегория finance-tooling, разделы C6/C7 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.18, Pravila v1.34, CLAUDE.md v2.21, ADR-012; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
**v3.17** — observer schema v2 sync (ADR-011 amend): R16.1 +предложение про `schema_version` / `decision_provenance` / `environment` / `task_size` / `prompt_signal` + расширенные события (`hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`) + `observer_error` маркер; R16.4 +cross-ref на factor-analysis spec и plan. R0R15 без изменений. Routing-gate / C5 controller / `/brain-retro` analyzer — нормативно в Pravila §16.7/§16.8 + ADR-011 §5; PSR_v1 фиксирует evidence-сбор (R16), не enforcement. Связано: ADR-011, Pravila v1.32 (§16 amend), spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`.
**v3.16** — Brain evidence loop: новое R16 «Brain evidence loop» (R16.1 observer scope — Stop-хук пишет episodes-YYYY-MM.jsonl, 5 обязательных полей incl. `primary_rationale`; R16.2 plugin stack-conscious events — `routing_decision` / `skill_invoked` с `node_id` при использовании R6/R6.1/R15, факторная матрица 5 осей для `/brain-retro`; R16.3 не override — R0–R15 определяют выбор, R16 только фиксирует историю; R16.4 cross-refs ADR-011 / Pravila §16 / spec+plan+procedure). R0R15 без изменений. Связано: ADR-011 `docs/adr/ADR-011-brain-governance.md`, Pravila §16, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`.
@@ -435,6 +439,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **hookify** *(skills `/hookify` / `/configure` / `/list` / `/help` + `writing-rules` + агент `conversation-analyzer`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | генератор хуков из анализа транскриптов диалога / явных инструкций. Категория: **authoring-tooling** (Tooling #58) | **только по явному `/hookify`**, не проактивно (HK2). **HK1 hard-rule:** перед генерацией хука — обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json`; перезапись 6-компонентной economy/skill-discipline архитектуры (economy-mode / skill-marker / skill-check / state-guard / postcompact / verifier) **запрещена**; при коллизии — остановка, ручное согласование. HK3 — закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Не UI → вне R6.0/R6.1/R14 |
| **claude-code-setup** *(skill `claude-automation-recommender`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | рекомендатель Claude Code automations — анализ кодовой базы + советы (хуки / суб-агенты / скилы / плагины / MCP). Read-only. Категория: **dev-support** (Tooling #59, вне UI-пула) | при запросе на оптимизацию Claude Code setup. CCS1 — рекомендации фильтруются R0 stack-gate + R10.1; ничего не устанавливается без явного согласования заказчика. Не UI → вне R6.0/R6.1/R14 |
| **context7** *(MCP-tools `query-docs` / `resolve-library-id`)* | `anthropics/claude-plugins-official` (Anthropic Verified) — плагин в `enabledPlugins`, не `.mcp.json`-сервер | актуальная документация библиотек / фреймворков / SDK — отдаёт upstream-доки, обходит cutoff training data. Категория: **dev-support** (Tooling #60) | **первый выбор** для документации **известной библиотеки** (Laravel / Vue / Vuetify / Pest / React / …). CTX1 — WebFetch для конкретного URL, WebSearch — поиск без знания библиотеки. Не UI → вне R6.0/R6.1/R14 |
| **finance** *(8 skills: `reconciliation` / `variance-analysis` / `financial-statements` / `close-management` / `journal-entry` / `journal-entry-prep` / `sox-testing` / `audit-support`)* | `anthropics/knowledge-work-plugins` (plugin `finance@knowledge-work-plugins`, Anthropic Verified, v1.2.0) | финансы/бухгалтерия — сверка, анализ отклонений, US-GAAP-отчётность, закрытие периода, проводки. Категория: **finance-tooling** (Tooling #61, вне UI-пула). Homed C7, cross-ref C6 | при учётно-финансовой работе. Применимость РФ: ✅ reconciliation/variance; ⚠️ US-GAAP-скилы частично; ❌ SOX-скилы not-applicable; warehouse-MCP DEFERRED (ADR-012). Не UI → вне R6.0/R6.1/R14 |
**Блок 1 — note (v3.3):** **mermaid-skill** (Tooling #37, генератор C4/architecture-диаграмм) — вендоренный сторонний скил в `.claude/skills/mermaid/` (`WH-2099/mermaid-skill`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Пассивная утилита (генерация Mermaid-исходника), не решатель — формально вне типологии трёх блоков; регистрируется здесь для полноты. Категория **architecture-tooling**, вне R6/R14.
@@ -450,6 +455,10 @@ Stack — **головной**. Все плагины вне stack'а — **ин
**Блок 1 — note (v3.13):** 5 Anthropic dev-плагинов — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — marketplace-плагины из `anthropics/claude-plugins-official`, включены в `~/.claude/settings.json` `enabledPlugins` user-level. Формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин включён без формализации» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Две новые off-phase подкатегории: **authoring-tooling** (13-я — #56-#58, создание Claude-артефактов) + **dev-support** (14-я — #59-#60, поддержка/документация Claude-разработки), не UI → вне R6.0/R6.1/R14. **hookify** несёт hard-rule HK1 (pre-check на коллизию с existing хуками). `context7` — плагин из marketplace (не `.mcp.json`-сервер Блока 3), хотя предоставляет MCP-tools. ADR-010, Tooling §4.31–§4.35.
**Блок 1 — note (v3.18):** **billing-audit** (Tooling #62) + **ru-tax-accounting** (Tooling #63) — self-authored project-скилы в `.claude/skills/billing-audit/` и `.claude/skills/ru-tax-accounting/`, **не** вендоренные и **не** через marketplace; написаны проектом (паттерн `audit-portal`/`regression`/`process-*`/`discovery-interview`). **Линтуются** lefthook'ом (cspell+markdownlint), **не** в ignorePaths (LINT1). Категория **finance-tooling** (15-я off-phase подкатегория, разделы C6/C7 карты), вне R6.0/R6.1/R14. ADR-012.
**Блок 1 — note (v3.19):** **Rector** (Tooling #64) + **PHP Insights** (Tooling #65) — Composer dev-dependencies (`rector/rector` + `driftingly/rector-laravel`; `nunomaduro/phpinsights`), **не** marketplace-плагины и **не** в `enabledPlugins` (как deptrac #43 / promptfoo #48). CLI-инструменты: Rector — авто-рефакторинг/version-upgrade (`composer rector`/`rector:fix`), manual/CI, dry-run baseline 16 файлов → **не** блокирующий lefthook; PHP Insights — метрики complexity/architecture (`composer insights`), on-demand/CI с порогами → **не** блокирующий (BT9). **laravel-backend-patterns** (Tooling #66) — self-authored project-скил в `.claude/skills/laravel-backend-patterns/`, **линтуется** (LINT1, как billing-audit/process-*). **NightOwl** (Tooling #67) — `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent`, **DEFERRED** (native-Windows нет pcntl/posix; OSS без MCP; hosted 152-ФЗ); при активации (Linux/Б-1) — MCP в Блок 3 или Boost `database-query`. Категория **backend-tooling** (16-я off-phase подкатегория, раздел A1 карты), вне R6.0/R6.1/R14. ADR-013.
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
@@ -816,7 +825,7 @@ Pravila §12 (Superpowers инвокация первой), §14 (queen-роут
- **UI-пул** (#31 UPM, #32 21st) — здесь R15 не применяется; R14 pipeline ведёт (это UI-задачи по природе).
- **infrastructure** (#33 claude-md-management) — единственный канал для правок CLAUDE.md (Pravila §5 п.10 + R10.1 Блок 1).
- **authoring-tooling** (#56-#58) — политика триггеров: skill-creator ≥3 повторений workflow → новый скил; hookify повторяющаяся ошибка → новый хук (с pre-check HK1); plugin-dev — для расширений plugin-grain.
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support** — следуют routing-off-phase.md.
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support / finance-tooling / backend-tooling** — следуют routing-off-phase.md.
### 15.7. Тип правила и enforcement
+10 -2
View File
@@ -1,10 +1,14 @@
# Правила работы Claude в проекте «Лидерра»
**Версия:** v1.33 (19.05.2026)
**Дата:** 19.05.2026
**Версия:** v1.35 (20.05.2026)
**Дата:** 20.05.2026
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
**Что изменилось в v1.35 относительно v1.34:** A1 backend-tooling — §13.2 +абзац «Off-phase backend-tooling»: #64 Rector + rector-laravel (Composer dev-dep, авто-рефакторинг/version-upgrade, manual/CI — dry-run baseline 16 файлов, не блокирующий), #65 PHP Insights (Composer dev-dep, метрики complexity/architecture, on-demand/CI — не блокирующий), #66 laravel-backend-patterns (self-authored project-скил, backend-конвенции Лидерры), #67 NightOwl (self-hosted runtime-телеметрия — **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ). 16-я off-phase подкатегория, раздел A1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-013. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.19, PSR_v1 v3.19, CLAUDE.md v2.22; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
**Что изменилось в v1.34 относительно v1.33:** finance-tooling (C6+C7) — §13.2 +абзац «Off-phase finance-tooling»: #61 finance plugin (marketplace `finance@knowledge-work-plugins`, Anthropic Verified, homed C7, cross-ref C6; РФ-применимость частична — US-GAAP-скилы ⚠️, SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 billing-audit (self-authored project-скил, C6 — денежные инварианты биллинга), #63 ru-tax-accounting (self-authored project-скил, C7 — РСБУ/НК РФ). 15-я off-phase подкатегория. Не UI → вне R6.0/R6.1/R14. Границы — ADR-012. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.18, PSR_v1 v3.18, CLAUDE.md v2.21; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
**Что изменилось в v1.33 относительно v1.32:** observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` | `user_directed_method` | `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из вариантов, предложенных Claude в предыдущем ходе — например `1`, `в делаем`, `делай 2`). §16.7 +абзац: routing-gate НЕ блокирует `user_chose_from_options` (выбор из choice-space, сформулированного самим Claude — не навязанный извне метод). Детектор — `tools/observer-choice-detector.mjs` (детерминированный, тег не требуется). Spec §11 `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`. Связано: CLAUDE.md v2.20.
**Что изменилось в v1.32 относительно v1.31:** observer factor-analysis extension (ADR-011 amend): §16.2 +абзац «Схема эпизода v2» (`schema_version: 2`, `decision_provenance`, `environment`, `task_size`, `task_ref`, `prompt_signal`; `outcome` `unknown` при записи; виды событий расширены `hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`); §16.3 4→5 контролёров (+C5 observer-coverage-checker, warn-only); §16.7 (новое) routing-тег-дисциплина — Stop-хук `decision: block` при навязанном методе без тега, `stop_hook_active` guard против петли; §16.8 (новое) самодисциплина наблюдателя (`observer_error` маркер вместо тихого пропуска, `parse_gap` событие, C5 контролёр); §16.6 +cross-ref на factor-analysis spec. Spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`. Связано: PSR_v1 v3.17, CLAUDE.md v2.19.
@@ -758,6 +762,10 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**Off-phase authoring-tooling + dev-support (v1.28, 18.05.2026):** 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level — формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин фактически включён без формализации в правилах» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Подкатегория **authoring-tooling** (тринадцатая, создание Claude-артефактов): #56 `skill-creator` (Tooling §4.31; конструктор standalone-скилов), #57 `plugin-dev` (§4.32; конструктор marketplace-плагинов — 8 sub-skills + 3 агента), #58 `hookify` (§4.33; генератор хуков). Подкатегория **dev-support** (четырнадцатая, поддержка/документация Claude-разработки): #59 `claude-code-setup` (§4.34; рекомендатель Claude Code automations, read-only), #60 `context7` (§4.35; актуальная документация библиотек). Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. **hookify** — особое правило: вызов только по явному `/hookify`, перед генерацией хука обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json` (перезапись 6-компонентной economy/skill-discipline архитектуры запрещена — конфликт-аудит HK1; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`). Границы D2–D5 — ADR-010. Регулируется PSR_v1 R10.1 Блок 1. Установлены 18.05.2026 на ветке `feat/anthropic-dev-tooling`; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
**Off-phase finance-tooling (C6+C7, v1.34, 20.05.2026):** Инструменты разделов C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и налоги» карты — #61 `finance` plugin (Tooling §4.36; marketplace `finance@knowledge-work-plugins`, Anthropic Verified, 8 скилов; homed C7, cross-ref C6; РФ-применимость: ✅ reconciliation/variance, ⚠️ US-GAAP-скилы частично, ❌ SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 `billing-audit` (Tooling §4.37; self-authored project-скил `.claude/skills/billing-audit/` — денежные инварианты биллинга C6: сохранение суммы bcmath, идемпотентность, tier-резолюция, дрейф reconcile, charge_source), #63 `ru-tax-accounting` (Tooling §4.38; self-authored project-скил `.claude/skills/ru-tax-accounting/` — РСБУ/НК РФ контекст C7: НДС/УСН, налоговая база, выгрузки бухгалтеру; закрывает РФ-gap US-плагина). Плюс reuse-классификация существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (Boost/Pest/Larastan/Sentry/Redis/PM metrics-review/data-scientist/operations/process-*/context7) — без новых номеров. **Пятнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы billing-audit/ru-tax-accounting **линтуются** (не в ignorePaths, LINT1). Границы — ADR-012 (граница C6↔C7: начисление клиенту vs учёт/налоги компании; FIN1–FIN8). Регулируется PSR_v1 R10.1 Блок 1 (finance plugin) + note (2 self-authored скила). Установлено 20.05.2026 на ветке `worktree-finance-tooling-c6-c7`; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
**Off-phase backend-tooling (A1, v1.35, 20.05.2026):** Инструменты раздела A1 карты «Программирование — backend» — #64 `Rector` + `rector-laravel` (Tooling §4.39; Composer dev-dependencies `rector/rector` + `driftingly/rector-laravel`, авто-рефакторинг/version-upgrade; конфиг `app/rector.php` deadCode+codeQuality conservative; постура manual/CI `composer rector`/`rector:fix` — dry-run baseline 16 файлов → **не** блокирующий lefthook, прецедент promptfoo ML1), #65 `PHP Insights` (Tooling §4.40; Composer dev-dependency `nunomaduro/phpinsights`; метрики complexity/architecture; конфиг `app/config/insights.php` — SyntaxCheck removed из-за Windows subprocess-краша, style-ось off — владелец Pint, BT4; постура on-demand/CI `composer insights` с порогами → **не** блокирующий, BT9), #66 `laravel-backend-patterns` (Tooling §4.41; self-authored project-скил `.claude/skills/laravel-backend-patterns/` — backend-конвенции Лидерры: слоистость/RLS-aware/bcmath-деньги/идемпотентность/partition-aware; **линтуется**, LINT1), #67 `NightOwl` (Tooling §4.42; `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent` — коррелированный runtime-трейс; **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ; pending Б-1/Linux). Плюс reuse существующих узлов A1 (Boost #10, Pint #11, Larastan #12). **Шестнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Rector/PHP Insights **не гейтят коммит** (manual/CI — избегаем дубля с Pint/Larastan/deptrac + авто-мутации кода). Границы — ADR-013 (BT1–BT9). Регулируется PSR_v1 R10.1 Блок 1 note. Установлено 20.05.2026 на ветке `worktree-a1-backend-tooling`; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
### 13.3. Скоуп
| Тип задачи | Кто отвечает |
+146 -4
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
# ADR-012: Finance-tooling — наполнение разделов карты C6 + C7
**Status:** Accepted
**Date:** 2026-05-20
**Контекст:** эпик finance-tooling (объединённые C6+C7), spec `docs/superpowers/specs/2026-05-20-finance-tooling-c6-c7-design.md`.
## Context
Разделы карты C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и
налоги» были пусты. Биллинг-подсистема (Plan 4) велика в коде, но dedicated dev-tooling
скуден. Заказчик решил объединить C6+C7 в один эпик и покрыть полностью.
## Decision
1. **finance plugin (#61)** (knowledge-work-plugins) — homed **C7** (primary), cross-ref C6.
- ✅ C6: `reconciliation`, `variance-analysis`.
- ⚠️ C7 частично (US-GAAP): `financial-statements`, `close-management`, `journal-entry`, `journal-entry-prep`.
- ❌ not-applicable РФ: `sox-testing`, `audit-support` (нет SOX у частной РФ-компании).
- DEFERRED: warehouse-MCP (snowflake/databricks/bigquery) — не стек проекта (PG+Redis).
2. **billing-audit (#62)** — self-authored project-скил, C6. Денежные инварианты Лидерры.
3. **ru-tax-accounting (#63)** — self-authored project-скил, C7. РСБУ/НК РФ. Закрывает gap US-плагина.
4. **Граница C6 ↔ C7:** C6 = начисление денег клиенту за лиды; C7 = учёт и налоги компании.
Точка стыка: billing-выручка (`lead_charges`/`LedgerService`) — выход C6 и вход C7.
5. **Reuse** существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (см. spec §6).
## Boundaries (конфликт-аудит)
- FIN1 warehouse-MCP → DEFERRED. FIN2 SOX → not-applicable РФ. FIN3 finance vs operations.
- FIN4 finance reconciliation (инструмент) vs CsvReconcileJob (код). FIN5 billing-audit vs process-*/D3.
- FIN6 ru-tax vs finance plugin vs D1/D2. FIN7 граница C6↔C7. FIN8 self-authored скилы линтуются.
## Consequences
- C6/C7 карты непусты. Новая off-phase подкатегория `finance-tooling` (15-я).
- Реальный платёжный провайдер и warehouse-аналитика — DEFERRED (Б-1 / вне стека).
- ru-tax-accounting — контекст/выгрузки, не налоговая консультация (бухгалтерия вне репо).
+57
View File
@@ -0,0 +1,57 @@
# ADR-013: A1 backend-tooling — наполнение раздела карты A1
**Status:** Accepted
**Date:** 2026-05-20
**Контекст:** эпик A1 backend-tooling, spec `docs/superpowers/specs/2026-05-20-a1-backend-tooling-design.md`.
## Context
Раздел карты A1 «Программирование — backend» был тонким — 3 узла: Boost #10
(Laravel-контекст), Pint #11 (стиль), Larastan #12 (типы). Backend-смежное уехало
в другие разделы (Pest→A5, squawk/pg_partman→A9, deptrac→A6, openapi→A3, Sentry/Redis→A7).
Дефициты чистого A1: авто-рефакторинг, метрики сложности/архитектуры, кодифицированные
backend-конвенции Лидерры, коррелированная runtime-телеметрия. На Anthropic-marketplace
чистого backend-кодинга нет (knowledge-work + meta); A1 закрывается GitHub PHP-экосистемой
плюс одним self-authored скилом.
## Decision
1. **Rector (#64)**`rector/rector` + `driftingly/rector-laravel` (Composer dev-dep).
Авто-рефакторинг + version-aware апгрейды. Конфиг `app/rector.php` — консервативный
старт (`deadCode` + `codeQuality`, БЕЗ type-declaration наборов и LaravelSetProvider).
- **Постура: manual/CI** (`composer rector` / `composer rector:fix`), **НЕ** блокирующий
lefthook. Spike dry-run = **16 файлов** (>5 порога → код-мутирующий инструмент не гейтит
коммит; прецедент promptfoo ML1). LaravelSetProvider — для разовых апгрейдов вручную.
2. **PHP Insights (#65)**`nunomaduro/phpinsights` (Composer dev-dep). Метрики
complexity / architecture / maintainability. Конфиг `app/phpinsights.php`.
- **Постура: on-demand/CI** (`composer insights` с порогами `--min-*`), **НЕ** блокирующий
lefthook (BT9 — избегаем четверного гейта Pint/Larastan/deptrac/Rector). Style-ось
выключена (владелец стиля — Pint); акцент Complexity + Architecture.
3. **laravel-backend-patterns (#66)** — self-authored project-скил (`.claude/skills/`).
Кодификация backend-конвенций Лидерры (слоистость / RLS-aware / bcmath-деньги /
идемпотентность / partition-aware). Активен.
4. **NightOwl (#67)** — self-hosted runtime-телеметрия. **DEFERRED** (pending Б-1 / Linux).
Блокер: native-Windows без `pcntl`/`posix` (агент не запускается); OSS-версия без MCP
(MCP только managed); hosted = риск 152-ФЗ. Spike + условия активации:
`docs/backend/nightowl-spike.md`. Прецедент: Sentry #34 / Figma #44 / Jupyter #50.
## Boundaries (конфликт-аудит)
- **BT1** Rector ↔ Pint: трансформация vs форматирование — разные операции.
- **BT2** Rector ↔ Larastan: Rector чинит, Larastan находит — комплементарны.
- **BT3** Rector ↔ deptrac: трансформация кода vs граф слоёв — ортогональны.
- **BT4** PHP Insights ↔ Pint/Larastan: style/code оси выключены; уникум = complexity + architecture.
- **BT5** backend-patterns ↔ architecture-patterns #38: project-specific vs generic.
- **BT6** backend-patterns ↔ billing-audit #62: генерация (как писать) vs аудит (проверка денег) — ссылка.
- **BT7** NightOwl ↔ Sentry #34: коррелированный трейс vs ошибки/трейсбэки.
- **BT8** NightOwl ↔ Pail / Boost: сквозной трейс vs tail / снапшот по требованию.
- **BT9** PHP Insights blocking? — нет (избегаем четверного гейта); on-demand/CI.
## Consequences
- A1 непуст: 3 → 6 узлов активных (Boost/Pint/Larastan + Rector/PHP Insights/backend-patterns) + 1 DEFERRED (NightOwl).
- Новая off-phase подкатегория `backend-tooling` (16-я).
- Rector и PHP Insights **не гейтят коммит** (manual/CI) — осознанно, чтобы не дублировать
существующие блокирующие гейты (Pint/Larastan/deptrac) и не авто-мутировать код на коммите.
- Rector оставляет разовый задел чистки (16 файлов) — применяется вручную через `composer rector:fix` с ревью + полным прогоном тестов, не в этом эпике.
- NightOwl — capability-readiness: задокументирован, активация при появлении Linux/боевого сервера (Б-1).
+84 -15
View File
@@ -2,7 +2,7 @@
// automation-graph-data.js — shared topology constants
// Consumed by:
// • docs/automation-graph.html (classic <script>, reads bare consts via shared lexical scope)
// • docs/brain-dashboard.html (classic <script>, same mechanism)
// • docs/observer/dashboard.html (classic <script>, same mechanism)
// Do NOT add ES-module syntax (import/export) — keep as classic script.
// ════════════════════════════════════════════════════
@@ -20,11 +20,12 @@ function pos(ring, angleDeg) {
}
const NODES = [
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
{ id: 'pravila', label: 'Pravila v1.29', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.16', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.14', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.15', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
{ id: 'pravila', label: 'Pravila v1.35', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.22', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.19', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.19', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
{ id: 'router_procedure', label: 'router-procedure v1.2', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
@@ -85,8 +86,19 @@ const NODES = [
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
// finance-tooling C6+C7 (20.05.2026) — разделы «Финансы»
{ id: 'finance_plugin', label: 'finance\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 200) },
{ id: 'billing_audit', label: 'billing-audit\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 397) },
{ id: 'ru_tax', label: 'ru-tax-accounting\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 407) },
// A1 backend-tooling (20.05.2026) — раздел «Программирование — backend»
{ id: 'rector', label: 'Rector\n(dev-dep)', group: 'plugins', size: 18, ring: 2, ...pos(2, 210) },
{ id: 'php_insights', label: 'PHP Insights\n(dev-dep)', group: 'plugins', size: 18, ring: 2, ...pos(2, 220) },
{ id: 'backend_patterns', label: 'backend-patterns\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 417) },
{ id: 'nightowl', label: 'NightOwl\n(DEFERRED)', group: 'mcp', size: 16, ring: 3, ...pos(3, 427) },
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
// ── ХУКИ (13) — S+infra + E (economy/skill/brain) ───
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
@@ -99,6 +111,8 @@ const NODES = [
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', size: 20, ring: 4, ...pos(4, 165) },
// brain governance iter9 (19.05.2026) — Stop-хук observer
{ id: 'observer_stophook', label: 'Stop:\nobserver-stop-hook', group: 'hooks', size: 22, ring: 4, ...pos(4, 205) },
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
@@ -129,7 +143,7 @@ const NODES = [
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
// ── LEFTHOOK JOBS (10) — S+W (infra/data) ─────
// ── LEFTHOOK JOBS (15) — S+W (infra/data/brain) ─────
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
@@ -140,8 +154,14 @@ const NODES = [
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
// brain governance iter9 (19.05.2026) — 5 контролёров C1-C5 (lefthook jobs 11-15)
{ id: 'lh_l1watcher', label: 'lefthook:\nl1-watcher (C1)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 150) },
{ id: 'lh_crossref', label: 'lefthook:\ncross-ref-checker (C2)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 157) },
{ id: 'lh_obs_obs', label: 'lefthook:\nobserver-of-observer (C3)',group: 'lefthook', size: 16, ring: 5, ...pos(5, 164) },
{ id: 'lh_status_md', label: 'lefthook:\nstatus-md (C4)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 171) },
{ id: 'lh_obs_cov', label: 'lefthook:\nobserver-coverage (C5)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 178) },
// ── MEMORY FILES (23) — внешнее кольцо ──────────
// ── MEMORY FILES (24) — внешнее кольцо ──────────
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
@@ -165,6 +185,8 @@ const NODES = [
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
// brain governance iter9 (19.05.2026) — хранилище evidence «мозга»
{ id: 'observer_evidence', label: 'docs/observer/\nepisodes+STATUS', group: 'memory', size: 16, ring: 6, ...pos(6, 204) },
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
@@ -366,6 +388,40 @@ const EDGES = [
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) — связи 9 новых узлов ──
E('claude_md', 'router_procedure', '§3.6: SoT\nпроцедуры роутера'),
E('tooling', 'router_procedure', '§4.X реестр →\nшаг 3 роутера'),
E('pravila', 'router_procedure', '§12/§14/§15\nhard-floor'),
E('pravila', 'observer_stophook', '§16: observer\n+ routing-тег'),
E('observer_stophook', 'observer_evidence', 'пишет эпизоды\n+ routing-gate'),
E('pravila', 'sk_brain_retro', '§16: факторный\nанализ раз в спринт'),
E('sk_brain_retro', 'observer_evidence', 'читает эпизоды\n(факторный анализ)'),
E('lh_l1watcher', 'tooling', 'C1 STRICT: settings.json\n↔ Tooling drift'),
E('lh_crossref', 'claude_md', 'C2 STRICT: version\ndrift §0 cross-refs'),
E('lh_obs_obs', 'observer_evidence', 'C3 warn: счётчик\n+54w self-prune'),
E('lh_status_md', 'observer_evidence', 'C4: генерит\nSTATUS.md'),
E('lh_obs_cov', 'observer_evidence', 'C5 warn: покрытие\n+ регистрация'),
// ── FINANCE-TOOLING C6+C7 (20.05.2026, ADR-012) — связи 3 узлов ──
E('tooling', 'finance_plugin', '§4.36 #61 — реестр'),
E('tooling', 'billing_audit', '§4.37 #62 — реестр'),
E('tooling', 'ru_tax', '§4.38 #63 — реестр'),
E('billing_audit', 'ag_pest', 'аудит инвариантов\nчерез тесты'),
E('mcp_boost', 'billing_audit', 'модели биллинга'),
E('finance_plugin', 'ru_tax', 'РФ-специфика поверх\nUS-механики (ADR-012)'),
E('billing_audit', 'ru_tax', 'выручка C6 →\nналог.база C7'),
// ── A1 BACKEND-TOOLING (20.05.2026, ADR-013) — связи 4 узлов ──
E('tooling', 'rector', '§4.39 #64 — реестр'),
E('tooling', 'php_insights', '§4.40 #65 — реестр'),
E('tooling', 'backend_patterns', '§4.41 #66 — реестр'),
E('tooling', 'nightowl', '§4.42 #67 — реестр'),
E('rector', 'php_insights', 'backend-quality\nchain L14'),
E('php_insights', 'lh_larastan', 'L14: метрики →\nтипы'),
E('rector', 'lh_pint', 'трансформация ↔\nстиль (BT1)'),
E('backend_patterns', 'billing_audit', '«как писать» ↔\n«аудит денег» (BT6)'),
E('mcp_boost', 'backend_patterns', 'Eloquent-контекст'),
E('nightowl', 'mcp_sentry', 'трейс ↔ ошибки\n(BT7, ADR-013)'),
// ══════════════════════════════════════════════════
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
@@ -378,6 +434,7 @@ const EDGES = [
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
CONFLICT('observer_stophook', 'hk_verifier', 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain). Оба способны decision:block; Claude Code прогоняет все Stop-хуки, любой block ⇒ продолжение хода. observer-gate детерминированный и дешёвый.', 'GREEN'),
// ══════════════════════════════════════════════════
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
@@ -469,7 +526,7 @@ const SECTIONS = [
{ id: 'E7', bucket: 'E', label: 'Исследования' },
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
];
// Узел -> раздел. Покрывает все 125 узлов карты.
// Узел -> раздел. Покрывает все 134 узла карты.
const NODE_SECTION = {
// правила (4)
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
@@ -527,22 +584,34 @@ const NODE_SECTION = {
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
discovery_interview: 'E5',
// brain governance iter9 19.05.2026 — ADR-011 подсистема
router_procedure: 'E1', observer_stophook: 'E2', sk_brain_retro: 'E8', observer_evidence: 'E4',
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
// finance-tooling C6+C7 (20.05.2026) — разделы «Финансы»
finance_plugin: 'C7', billing_audit: 'C6', ru_tax: 'C7',
// A1 backend-tooling (20.05.2026) — раздел «Программирование — backend»
rector: 'A1', php_insights: 'A1', backend_patterns: 'A1', nightowl: 'A1',
};
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
const NODE_SECTION_SECONDARY = {
mcp_boost: ['A3'],
context7: ['A3'],
ag_pest: ['A3'],
mcp_boost: ['A3', 'C6', 'C7'],
context7: ['A3', 'C6'],
ag_pest: ['A3', 'C6', 'C7'],
mcp_semgrep: ['A3'],
mcp_sentry: ['A3'],
mcp_sentry: ['A3', 'C6'],
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
mermaid_skill: ['C10'],
arch_patterns: ['C10'],
ccpm: ['C10'],
product_mgmt: ['C10'],
product_mgmt: ['C10', 'C6'],
sk_wplans: ['C10'],
// finance-tooling C6+C7 (20.05.2026) — finance cross-ref + reuse-классификация
finance_plugin: ['C6'],
lh_larastan: ['C6'], mcp_redis: ['C6'],
data_scientist: ['C6', 'C7'], ops_plugin: ['C6', 'C7'],
process_modeling: ['C6'], process_analysis: ['C6'],
};
// ════════════════════════════════════════════════════

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