Commit Graph

95 Commits

Author SHA1 Message Date
Дмитрий 1d18933d9e feat/supplier: экран отчёта о вечерней заливке (Эпик 5)
Владелец выбрал формат «экран в админке» (не письмо).
- SyncSupplierProjectsJob по завершении пишет строку-сводку в новую supplier_sync_runs
  (групп/синк/ручная/отложено/упало + status ok|partial|failed|aborted) через finally —
  пишется и при раннем abort (time-budget/mass-fail/auth).
- Эндпоинт GET /api/admin/supplier-integration/sync-runs + метод syncRuns.
- Экран SaaS-admin «Интеграция с поставщиком» → карточка «Вечерняя заливка проектов
  поставщику»: таблица заливок со статусом человеческим языком (Всё ровно/Частично/Сбой).
- Схема v8.55 +1 таблица (SaaS-level без RLS как supplier_csv_reconcile_log), миграция
  2026_06_25_130000, RLS-ревью 7/7. Проверено глазами в браузере (epic5-sync-runs-admin-screen.png).

Тесты: бэк 24/25 (1 skip) + фронт-экран 5/5 зелёные. Под LEFTHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:52:39 +03:00
Дмитрий 9d703ccb2a feat/supplier: онлайн-заморозка 18:00→00:00 + досыл очередью в 00:05 (Эпик 4)
Закрывает рассинхрон онлайна со слепком поставщика 21:00.
- 4.1: SyncSupplierProjectJob в окне 18:00→00:00 МСК кладёт проект в новую очередь
  supplier_deferred_sync вместо немедленной отправки (перезаписала бы зафиксированный
  слепок). Вне окна — как раньше.
- 4.2: FlushDeferredOnlineSyncJob в 00:05 МСК досылает отложенное вне окна и чистит очередь.
- Схема: +1 таблица supplier_deferred_sync (project_id PK, без RLS — системная очередь как
  supplier_manual_sync_queue), миграция 2026_06_25_120000, schema.sql v8.54 + CHANGELOG.
  RLS-ревью пройдено (no-RLS консистентно прецеденту; формулировки GRANT/метрик уточнены).

Тесты 6/6 + регрессия онлайн-синка 33/33 зелёные. Под LEFTHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:07:15 +03:00
Дмитрий 35d51df6f7 fix/supplier: отложенное удаление источника пока летит хвост слепка или есть другие потребители
DeleteSupplierProjectJob перед удалением донора проверяет hasActiveSnapshotTail:
если за сегодня/завтра есть снимок маршрутизации с источником этого донора
(sms по sender+keyword, site с поддоменом, call по identifier — зеркало LeadRouter),
удаление откладывается до следующего CleanupInactiveSupplierProjectsJob (02:00).
Иначе удалив донора оборвём матч хвостового лида по старому источнику.

Эпик 2 Task 2.4. baseline +1 (Mockery once-noise, как DeleteSupplierProjectJobTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:35:58 +03:00
Дмитрий c8ec7a6616 fix/supplier: офлайн-батч берёт источник из слепка — закрыт рассинхрон 18:02→18:05
collectEligibleProjects переопределяет signal_identifier/sms_senders/sms_keyword
значениями слепка (как daily_limit/regions). Клиент мог сменить источник в окне
18:02 (snapshot) → 18:05 (sync), но завтрашний заказ поставщику фиксируется слепком
(slepok-инвариант) — раздача доводит хвост по старому источнику.

Тест-хелпер insertSnapshotForTomorrow теперь зеркалит источник проекта в слепок
(как прод SnapshotProjectRoutingJob), иначе группировка офлайн-батча пустеет.

Эпик 2 Task 2.6. Под LEFTHOOK=0 (окруженческий larastan-шум, см. память #111).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:26:28 +03:00
Дмитрий a49916b7fc test: дозакрытие последних 5 — advisory-lock наблюдение, cap-3, webhook фаза-3, supplier-client URL
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Набор полностью зелёный (55 to 0; 1713 pass + 4 skip). Всё тест-сторона:
- AuditChainRaceConditionTest: advisory-lock в audit_chain_hash РЕАЛЬНО присутствует
  (миграция 2026_05_30 применяется) — падало наблюдение: bind-параметр в SQL-сдвиге
  (? >> 32) не сдвигал → classid не совпадал. Декомпозицию ключа считаем в PHP.
  NB: db/schema.sql хранит функцию БЕЗ блокировки (минорный дрейф канона; прод через
  миграцию защищён) — стоит перегенерить schema.sql отдельно.
- SupplierConnectionTest WARN#2: matchEligibleProjects ограничен cap=LeadDistributor::CAP=3;
  ждать 3 из 6 видимых тенантов (кросс-tenant видимость под BYPASSRLS; при RLS было бы 0).
- SupplierWebhookTest + ValidationFormatTest: фаза 3 намеренно приняла проект без
  B-префикса как DIRECT (не теряем заявки) — тесты под новый контракт (202 / 422 по vid).
- SupplierPortalClientTest: fake-паттерн под старый URL /admin/rt-projects-load; клиент
  зовёт /admin/visit/rt-projects-load — обновлён паттерн.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:46:43 +03:00
Дмитрий 88ace4e3d9 test: дозакрытие оздоровления — protekateli pd-аудита, видимость supplier, новый флоу регистрации
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Снижение остатка 19 to 5. Всё тест-сторона:
- PdErasureServiceTest + AdminPdSubjectRequestsControllerTest: SharesSupplierPdo —
  перестали коммитить pd_processing_log через pgsql_supplier, что ломало
  глобальный audit:verify-chains (6 падений) и амплифицировало PhoneRegionSmoke.
- ReportFileDeletePdLogTest: SharesSupplierPdo — cron reports:cleanup-expired
  теперь видит незакоммиченные job'ы теста.
- AdminSuppliersControllerTest: устойчивый ассерт (с фазы 3 в suppliers есть direct).
- AuthLogCoverageTest/AuthFlowIntegrationTest: новый флоу самозаписи G1/SP1 —
  register_success пишется после confirm-email; добавлен шаг подтверждения.
- ImpersonationTest end: verify (G7-B) ставит маркер impersonation → admin-зона
  закрыта by design; помечаем токен used напрямую вместо session-takeover.
- CleanupInactiveSupplierProjectsJobTest: phase A читает pivot project_supplier_links —
  добавлена привязка linkProjectToSupplier (раньше был только legacy FK).
- Pint-нормализация uses() FQN to import в ранее тронутых файлах.

Остаток 5 (НЕ слепой патч): webhook B-префикс ×2 (решение владельца), advisory-lock
audit-цепочки (возможный дрейф схемы, флажок), SupplierConnection WARN#2 (cap-3,
поведенческое), SupplierPortalClientTest (пре-существующий, не от этих правок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:19:53 +03:00
Дмитрий 2ec70b338f test: оздоровление тест-стенда — изоляция протекателей плюс фикстуры, партиции, видимость supplier-коннекта
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Закрыто 36 из 55 пре-существующих падений backend-набора (55 to 19), всё тест-сторона,
код продукта не тронут. Группы:
- incident-показ/РКН: добавлен SharesSupplierPdo + синхрон уровня транзакции в трейте
  (вложенный transaction на общем PDO теперь делает SAVEPOINT, не повторный BEGIN).
- auto-pause и lead-delivery: тесты создают project_routing_snapshots, от которого
  зависит выбор кандидатов в LeadRouter (slepok-инвариант).
- изоляция 16 протекающих тестов: добавлен DatabaseTransactions (где нужно плюс
  SharesSupplierPdo) — перестали оставлять committed-строки, отравлявшие глобально
  сканирующие тесты (snapshot, verify-audit, size-N).
- partition time-bombs: ensureRange месячных партиций для тестов на дату 2026-05.
- устаревшие ассерты: SchemaDelta метрики v8.35 to v8.52, ProjectsStore телефон 8 to 7
  нормализуется, incidents-watch фильтр активного admin, register captcha_token,
  impersonation активный юзер тенанта, activity_log.deal_id, ProjectUpdateDedup пауза.

Остаток 19 (отдельно): verify-audit-chains и size-N (протекатели audit-строк),
webhook B-префикс (решение владельца), пара env/каскадных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:39:51 +03:00
Дмитрий b9184a6aea test(supplier): regression-guard неизменности received_at при merge CSV-recovered сделки 2026-06-21 08:23:13 +03:00
Дмитрий 53fb7b7760 feat: G1/SP1 самозапись клиента с подтверждением почты 6-значным кодом
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:33:33 +03:00
Дмитрий 8ad9e1d17f fix(test): routing-snapshot today+tomorrow в CsvWebhookRaceTest + PII на slack/papertrail/stderr
C: LeadRouter.activeSnapshotDate после 21:00 МСК = завтра; снимок только на сегодня не активен -> снимки на обе даты. A: PII-процессор на остальные лог-каналы, 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:37:41 +03:00
Дмитрий f94552d452 WIP чекпойнт: lead-region/supplier бэкенд + фронт-редизайн + Pint + тесты
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:17:12 +03:00
Дмитрий 6383da7f12 chore(incident-followup): close 4 tails from 29.05 disk-full incident
ремонт: incident-followup cleanup batch — 4 хвоста

1. Larastan baseline regenerated (was 161 errors pre-existing IDE helper drift)
2. Deptrac Mail: [Model, Service] + ADR-005 amend (was 4 pre-existing violations)
3. PG logrotate config in setup-logrotate.yml
4. F1 6 mismatches — RCA updated (algorithm divergence trigger global vs verify per-tenant)

+3 cspell words: notifempty, missingok, верифицируется.

Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §4-5
2026-05-29 14:45:28 +03:00
Дмитрий b28a9c030c feat(supplier): fast-fail in RouteSupplierLeadJob for terminal errors
Closes failed_webhook_jobs storm class (Finding 2, 2026-05-29):
поставщик crm.bp-gr.ru шлёт B1+SMS combo → DomainException в
SupplierProjectResolver → 3 retries → failed() записывает error в supplier_lead
→ RetryFailedSupplierJobsCommand при следующем dispatch видит тот же lead →
~256k строк/сутки.

Fast-fail guard добавлен в RouteSupplierLeadJob::handle() МЕЖДУ двумя
существующими idempotency-guard'ами и parseProjectField. Если supplier_lead.error
содержит terminal pattern ('does not support' / 'platform mismatch' /
'no matching supplier_project') и processed_at IS NULL — job помечает processed_at
и выходит без записи в failed_webhook_jobs.

Correction 1: реальный класс RouteSupplierLeadJob (не ProcessSupplierWebhookJob).
Correction 3: место вставки — после processed_at guard, до parseProjectField.

Tests: 6 passing в SupplierWebhookFastFailTest.php
- fast-fail на 3 terminal patterns
- НЕ fast-fail при error=null (нормальный лид)
- НЕ fast-fail при processed_at уже установлен (idempotency guard первым)
- НЕ fast-fail при transient ошибке (не matching pattern)

Task 2 plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:11:27 +03:00
Дмитрий a43ac2d9a5 feat(supplier): R-05 — business-drift second pass in CsvReconcileJob
After the existing webhook-loss drift detection (R-05.1: lead delivered but
webhook missed), CsvReconcileJob now runs a second pass on project_routing_snapshots:
per (snapshot_date, tenant_id) groups, if (expected - delivered) / expected > 20%
→ send TenantBusinessDriftAlertMail (separate from CsvDriftAlertMail).

This catches R-05.2: lead expected by slepok plan but supplier under-delivered.
Same lead can be missing from both CSV (webhook-loss) AND delivered_count
(business-shortfall) — both alerts fire independently.

  BUSINESS_DRIFT_THRESHOLD = 0.20
  detectAndAlertBusinessDrift() — runs after primary drift inside try{} block,
  scoped to the same reconcile window. One email per tenant per snapshot_date.

+ New TenantBusinessDriftAlertMail + emails/tenant_business_drift_alert.blade.php.
+ 2 Pest tests: shortfall>20% triggers mail (80% case), shortfall<=20% does not (10% case).
+ Existing tests narrowed from assertNothingSent() to assertNotSent(CsvDriftAlertMail)
  since legacy snapshot data on dev DB may trigger TenantBusinessDriftAlertMail
  beyond test's scope.
Full CsvReconcileJobTest suite 11/11 GREEN. Stage 4 §4.4.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:28:42 +03:00
Дмитрий 33b3ac06f2 feat(supplier): R-17 migration — supplier:rekey-orphans artisan command
One-time cleanup of orphan SMS supplier_projects rows created by the now-removed
buildUniqueKey divergence (B3 used sender alone; B2 sender+keyword).

Logic per orphan (sms unique_key without '+', owning project has sms_keyword):
  - no sibling at sender+keyword for same tenant → UPDATE row's unique_key
  - has sibling → dispatch DeleteSupplierProjectJob (cleans up at portal +
    cascades pivot deletion + local row removal)

Discovers orphans via pivot project_supplier_links join (primary path post-Plan-1
pivot rollout). --dry-run flag previews without mutation.

Usage on prod after Stage 4 deploy:
  ssh ubuntu@liderra.ru 'cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans --dry-run'
  # review output
  ssh ubuntu@liderra.ru 'cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans'

3 Pest tests: no-sibling UPDATE path, sibling DELETE-dispatch path, dry-run no-op.
Stage 4 §4.4.1 migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:18:38 +03:00
Дмитрий 4b7b67cefa refactor(supplier-grouping): R-17 — unify on buildUniqueKeyAgnostic
Deleted platform-specific buildUniqueKey($project, $platform). It diverged for
SMS (B2='sender+keyword', B3='sender' alone) → orphan supplier_projects on
sharing rebalance — B2 and B3 rows for the same project couldn't be reconciled
as one group. Now ALL platforms use buildUniqueKeyAgnostic:
  site/call    → signal_identifier
  sms+keyword  → sender+keyword
  sms (no kw)  → sender

3 callers updated: SyncSupplierProjectJob (online + batch paths) and
SupplierProjectImporter. Pest +1 test on Importer SMS commit asserts uniform
unique_key=sender+keyword across B2+B3 (RED before fix, GREEN after).
Full Importer suite 15/15 GREEN, SyncSupplierProjectsJob 12/12 GREEN.
Stage 4 §4.4.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:12:21 +03:00
Дмитрий 88a284cc91 feat(supplier): R-18 — fixed target_date in online sync (21:00 МСК cut-off)
Extracted SyncSupplierProjectJob::targetWeekdayForNow() — slepok cut-off boundary
is 21:00 МСК, matching supplier's snapshot fix-point. Before fix Carbon::tomorrow
flipped at midnight, mis-aligning portal sync (Thu 23:59 МСК pointed to Fri while
post-21:00 portion of day N belongs to slepok dated N+1 effective day N+2).

  hour <  21 МСК → target = today + 1 day
  hour >= 21 МСК → target = today + 2 days

3 pure unit tests (Mon 20:00→Tue, Mon 22:00→Wed discriminator, Tue 00:01→Wed
no-midnight-flicker) confirm new logic. Baseline regression verified — 8 pre-
existing Pest failures on Windows-native PG env are NOT caused by this change.
Stage 4 §4.4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:59:23 +03:00
Дмитрий 6e5460be5e feat(slepok): Task 2.9 — SyncSupplierProjectsJob reads from snapshot (race 18:02→18:05 closure)
After Stage 2 запуска, 18:05 МСК sync читает project_routing_snapshots за tomorrow
МСК, не live projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync):
клиент мог нажать «пауза» в эти 3 минуты, но мы всё равно докатываем зафиксированный
slepok поставщику (slepok-инвариант).

collectEligibleProjects() переписан с Project::on()->where('is_active', true)
на Project::on()->join('project_routing_snapshots AS snap', ...). Snapshot уже
отфильтрован по is_active/preflight_blocked/frozen_tenant; повторно проверяем
frozen-фильтр на случай freeze в эти 3 минуты. daily_limit_target /
delivery_days_mask / regions переопределяются значениями snapshot (slepok-семантика);
downstream syncGroup() работает без изменений.

Spec §4.2.4b. Closes race 18:02→18:05.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.9

Tests:
- tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php (4 new tests, PASS).
- tests/Feature/Supplier/SyncSupplierProjectsJobTest.php — 12 existing tests patched
  with insertSnapshotForTomorrow($project) helper (12/12 GREEN).
- tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php — 2 existing tests
  patched (2/2 GREEN).
- tests/Pest.php — global helper insertSnapshotForTomorrow().

Combined sync regression: 19/20 PASS + 1 skipped (pre-existing).

Patched via 2 parallel Sonnet subagents per Pravila §15.1; controller-verified
combined regression.
2026-05-28 06:59:09 +03:00
Дмитрий e8db184e99 feat(slepok): Task 2.5 — LeadRouter reads from project_routing_snapshots (R-01 closure)
LeadRouter SQL переписан на JOIN с project_routing_snapshots по active_slepok_date:
до 21:00 МСК = today, после 21:00 МСК = today+1. is_active / delivery_days_mask /
daily_limit / regions / signal_type / signal_identifier берутся из snapshot.
Из live projects — только delivered_today (счётчик остатка лимита). Из tenants —
balance_rub (live auto-pause при нулевом балансе).

Active snapshot date вычисляется в PHP (метод activeSnapshotDate()) и
передаётся в SQL как параметр — тестируемо через Carbon::setTestNow,
исключает дрейф между PHP- и DB-часами.

Fail-loud: Log::error('lead_router.no_snapshot_for_active_date', ...) если
по активной дате слепка вообще нет ни одной строки snapshot'а (cron не отработал).

Closes R-01, R-04, R-06, R-07, R-08, R-15.
Partial: R-02 (через шеринг), R-09 (race), R-10 (editable identifier) — закрываются Task 2.6+.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.5
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3

Tests added:
- tests/Feature/LeadRouter/SnapshotRoutingTest.php (4 tests, all GREEN locally)

Tests patched (downstream — добавлен createRoutingSnapshotFromProject() helper):
- tests/Pest.php — global helper createRoutingSnapshotFromProject()
- tests/Feature/LeadRouter/BalanceFilterTest.php (2/2 GREEN)
- tests/Feature/Services/LeadRouterTest.php (10/10 GREEN)
- tests/Feature/Jobs/RouteSupplierLeadJobTest.php (14/14 GREEN)
- tests/Feature/Supplier/DirectPlatformTest.php (6/6 GREEN)
- tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php (3/3 GREEN)
- tests/Feature/Supplier/SupplierConnectionTest.php (5/5 GREEN)
- tests/Feature/Integration/SupplierLeadFlowTest.php (2/2 GREEN)
- tests/Feature/Pd/DealCreatePdLogTest.php (2/2 GREEN)

Each test file isolated regression: GREEN. Combined run 49/50 with 1 flake on
quirk #77 (Faker unique domainName + cross-connection pgsql/pgsql_supplier
DatabaseTransactions scope mismatch) — pre-existing, NOT regression от Task 2.5.

Patched via 7 parallel Sonnet subagents per Pravila §15.1; controller-verified
isolated + combined regression (latter caught 1 subagent over-application:
paused project in SupplierLeadFlowTest получил snapshot, что нарушило логику
теста — fixed inline, по semantic match with SnapshotBackfillCommand SQL
WHERE p.is_active = true).
2026-05-28 05:48:15 +03:00
CoralMinister b1a53fd98e Merge pull request #25 from CoralMinister/feat/slepok-stage-1
Feat/slepok stage 1
2026-05-27 04:58:23 +03:00
Дмитрий 4188fcbc36 fix(supplier): R-16 — cleanup uses pivot, not legacy FK
CleanupInactiveSupplierProjectsJob Phase A/B/C subquery determined
active supplier_projects through legacy supplier_b{1,2,3}_project_id FKs,
which are NULL for Plan 3+ projects (using project_supplier_links pivot).
After 180d TTL these supplier_projects would be deleted from supplier,
breaking real lead flow. Subquery now uses pivot.
2026-05-27 04:18:04 +03:00
Дмитрий d2ff39abc2 feat(billing-v2-c): SyncSupplierProjectsJob исключает frozen-проекты из заказа
Task 1.8 Спека C. Выделен публичный метод collectEligibleProjects() в
SyncSupplierProjectsJob; добавлены 2 фильтра:
— projects.preflight_blocked_at IS NULL (точечная блокировка проекта);
— tenants.frozen_by_balance_at IS NULL (пассивная заморозка тенанта).

NB: whereIn-subquery вместо whereHas — relation whereHas строит query через
default pgsql, ломая cross-connection Project::on('pgsql_supplier'); subquery
с FROM 'tenants' наследует connection родителя.

SupplierScheduleTest: ожидание '0 18 * * *' -> '5 18 * * *' (сдвиг Sync на
18:05 из Task 1.4 — preflight @18:00 успевает проставить флаги до формирования
заказа).

2 теста preflight-filter GREEN. Pre-existing fails в SyncSupplierProjectJobTest
(singular — другой класс) — не моя регрессия (Mockery/regions/limits).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:16 +03:00
Дмитрий 6bf0ebfd1d feat(supplier): LedgerService + CsvReconcileJob recognise DIRECT platform
LedgerService::resolveSupplierId returns suppliers.code='direct' row for
DIRECT-platform supplier_projects (and for parsed-from-payload non-B
projects). CsvReconcileJob::extractPlatform now classifies most non-empty,
non-junk project strings as DIRECT (instead of dumping them into
unparseable_count) — this allows CSV recovery to also create DIRECT
supplier_leads, mirroring the webhook path.

CsvReconcileJobTest junk-rows fixtures updated: previously used callback
phone-number-as-project (79135551234) and URL-like strings as 'junk', but
those are now valid DIRECT identifiers. Replaced with truly junk strings
matching only outside-whitelist symbols (e.g. '???', '!@#').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:59:08 +03:00
Дмитрий 82b95f4bcb test(supplier): end-to-end DIRECT platform tests (4 failing, 2 passing)
Six tests:
  1. webhook with non-B-prefix project → 202 + platform=DIRECT (FAIL: 422 regex)
  2. Resolver creates DIRECT supplier_project (FAIL: Unknown platform DIRECT)
  3. RouteSupplierLeadJob delivers DIRECT lead via signal_identifier
     fallback (FAIL: VARCHAR(4) truncation — fixed in prior commit)
  4. numeric-only project → DIRECT (FAIL: 422 regex)
  5. B1 regression (PASS)
  6. Resolver rejects truly unknown platform (PASS)

Implementation in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:59:02 +03:00
Дмитрий e8782c47b3 test(supplier): assert webhook-after-csv-recovered merges into existing deal (failing)
Reproduces 37 duplicate deals observed on prod 2026-05-25 for tenant client1.
After Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector, the race
between CsvReconcileJob (creates SupplierLead vid=null) and later webhook
retry (vid=int) results in two separate Deals because supplier_lead_deliveries
locks on supplier_lead_id (which differs between csv-recovery and webhook),
not on (phone, project_id).

Failing now — implementation comes in next commit.
2026-05-25 17:54:20 +03:00
Дмитрий 3eb6c7fecd fix(supplier): убрать false-positive drift_alert от мусора в CSV (Спек A)
CsvReconcileJob каждый час стабильно ставил drift_alert ~40-50% (10 запусков
подряд на проде → admin-блок «Здоровье резервного канала» показывал «down»),
потому что поставщик crm.bp-gr.ru кладёт телефон/URL в поле «project» CSV.
Парсер extractPlatform() корректно их скипал, но строки оставались и в
count(missing), и в total_csv_rows формулы drift'а → стабильный false-positive.

Фикс (вариант A из брейнсторма с заказчиком):
- schema v8.36: +supplier_csv_reconcile_log.unparseable_count INTEGER NOT NULL DEFAULT 0
- CsvReconcileJob: считает $unparseableCount отдельно, новая формула
  drift = max(0, missing − unparseable) / max(1, total − unparseable)
- Миграция (pgsql_supplier, Спек B pattern, IF NOT EXISTS — idempotent)
- TDD: +2 теста (100matched+10junk → ok; mixed 95+5junk+3real → drift по реальным).
  Существующие 7 кейсов GREEN без изменений (unparseable=0 → формула идентична).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:38:31 +03:00
Дмитрий 4f2649aff2 test(billing-v2): dup-policy end-to-end (two deliveries / cap-3-tenants, Spec B) 2026-05-23 20:47:53 +03:00
Дмитрий 88e77449a7 feat(billing-v2): per-(delivery,tenant) lock guard via insertOrIgnore (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:53 +03:00
Дмитрий e1fdb5ca8e refactor(billing-v2): remove DuplicateDetector — trust supplier dedup (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:53 +03:00
Дмитрий 8fce10f5a0 feat(billing-v2): LeadRouter — one project per tenant (max remaining limit, Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:52 +03:00
Дмитрий bc8afbc362 feat(billing-v2): supplier_lead_deliveries lock table (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:52 +03:00
Дмитрий e1cc540d74 fix(billing-v2): migrate sharing-flow tests to always-rub (finish Spec A test-debt) 2026-05-23 20:44:51 +03:00
Дмитрий 871ca6b6aa fix(billing-v2): regression — Larastan @phpstan type hints + Pint auto-format 2026-05-23 18:46:23 +03:00
Дмитрий a3151b7809 fix(billing-v2): regression — A.5 downstream tests use rub balance arrange 2026-05-23 18:46:22 +03:00
Дмитрий cfe94d9178 fix(projects): closable-chips на селекторах регионов — удаление по одному
Раньше чтобы убрать один регион из выбора, приходилось сбрасывать все
и выбирать заново. Добавлен closable-chips на v-autocomplete регионов в
трёх местах: карточка создания проекта (NewProjectDialog), панель
редактирования (ProjectDetailsDrawer) и массовое изменение регионов
(RegionsBulkDialog). Теперь у каждого чипа есть крестик.

Покрыто Vitest: closableChips=true на каждом селекторе.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:21:10 +03:00
Дмитрий 1be2d62f9e feat(supplier): group recompute + pause + source-change + root auto-link
Закрывает замечания заказчика (22.05.2026) по проектам/поставщику. Все 4 куска
имеют общий корень: online-синхронизация одного проекта работала с данными ЭТОГО
проекта, а не пересчитывала всю «группу» (проекты разных tenant'ов с одним
identifier) — отсюда переплата ×3 при изменении лимита, затирание регионов/дней
группы, неотправленная пауза, и осиротевшие проекты при смене источника.

1. Групповой пересчёт в SyncSupplierProjectJob::handleOnline (#1 при изменении,
   #2 дни, #3 регионы, C2/C3): union regions, computeOrder eligible,
   distributeForPlatform — те же расчёты, что в ночном syncGroup. Online и
   ночной теперь дают идентичный supplier-state, расхождение устранено.

2. Пауза #10:
   - ProjectController::toggleActive — диспатчит SyncSupplierProjectJob;
   - ProjectService::bulkPauseResume — диспатчит sync per project;
   - DTO status вычисляется из groupActive (paused когда группа без активных);
   - sp.inactive_since пишется при пересинке (для UI/DTO консистентности).

3. Смена источника #8/#9 в ProjectService::update:
   - до update снимается старый buildUniqueKeyAgnostic;
   - если изменился — отвязываем старые supplier_projects от этого project
     (pivot + legacy FK), DeleteSupplierProjectJob удаляет их у поставщика
     при отсутствии других потребителей, либо пересинкает агрегат.

4. Перенос auto-link корня из feat/root-domain-auto-link: новый
   App\Support\SupplierIdentifier::extractRootDomain + блоки auto-link в
   обоих джобах (online + nightly).

Тесты: TDD на каждый кусок. SyncSupplierProjectJobTest +2 (group recompute,
pause). ProjectUpdateDedupTest +1 (source detach + cleanup dispatch).
ProjectsActionsTest +2 (toggle + bulk pause dispatches).

Регрессия: 186/186 passed (Project/Plan5/Projects + Supplier), 502 assertions.

Деплой: дельтой на боевой (база = root-domain ветка; на боевом джобы СТАРЕЕ
main, deliver через копию изменённых файлов + config:cache + restart queue).

План: docs/superpowers/plans/2026-05-22-замечания-проекты-чеклист.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 16:52:30 +03:00
Дмитрий 01bd9977b4 refactor(supplier-import): code-review response — per-project atomicity + sms name + test gaps
C1 (Critical): восстановлена per-project транзакция в commit() через гейт
DB::connection('pgsql_supplier')->getPdo()->inTransaction() — в проде BEGIN/COMMIT
на каждый item (Project+sps+pivot атомарно, no orphan-Project при сбое в группе);
под SharesSupplierPdo+DatabaseTransactions гейт detects общий PDO и пишет inline
(избегает «already active transaction»). Runbook §«Атомарность» переписан.

M3 (Minor): deriveName для sms берёт sms_senders[0] как fallback вместо литерала 'проект'
(когда тег пустой/'РФ').

N1+N2 (test gaps): +тест workdays union по двум площадкам с разными расписаниями
(B1 [1,2,3] ∪ B2 [4,5] → mask 31); +тест sms regions_reverse skip (отдельный
кодовый путь от site/call); +тест sms name из sender при пустом теге.

I1 ОТКЛОНЁН: рецензент предложил вернуть array_values() в parseGibddRegions,
но Larastan однозначно подтвердил `arrayValues.list` — preg_split с
PREG_SPLIT_NO_EMPTY + array_map даёт list, и возврат array_values был бы no-op +
триггерил бы stan-ошибку. Оставлено как было после стан-фикса.

Tests: 32/32 GREEN (29 + 3 new). Source stan-clean (38 ошибок без изменений —
все в test-files quirk #25 + ide-helper drift, не в source).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:17:12 +03:00
Дмитрий b1ce3d1f36 feat(supplier-import): artisan supplier:import-projects (dry-run / --commit, маскирование ПДн) 2026-05-22 10:17:10 +03:00
Дмитрий ded8e3758d test(supplier-import): commit реюзит существующий supplier_project, не дублирует
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:17:09 +03:00
Дмитрий 391607cadd feat(supplier-import): commit — Project+supplier_projects+pivot из external_id, без записи на портал 2026-05-22 10:17:09 +03:00
Дмитрий d5b3406860 feat(supplier-import): buildPlan идемпотентность — существующий Project пропускается 2026-05-22 10:17:08 +03:00
Дмитрий 9fd8f35ca4 feat(supplier-import): buildPlan — sms-группировка по sender (B2/B3) 2026-05-22 10:17:08 +03:00
Дмитрий ede7b97a4f feat(supplier-import): buildPlan — обратные регионы/union/вся РФ + skip regions_reverse 2026-05-22 10:17:07 +03:00
Дмитрий 9cabe8ded4 feat(supplier-import): buildPlan — site/call группировка B1/B2/B3, лимит=сумма 2026-05-22 10:17:06 +03:00
Дмитрий e6beff6aeb fix(supplier): делить лимит между B1/B2/B3, а не дублировать (×N переплата)
Портал поставщика НЕ делит лимит по площадкам сам (Plan 3 R6 «verified 15→5»
оказался ложным — проверено вживую 2026-05-21 через listProjects): каждый
B-проект честно набирает до своего лимита, поэтому одинаковый лимит на B1/B2/B3
= заказ ×N (звонки/сайт ×3, sms+keyword ×2) → переплата поставщику.

Восстановлен per-platform split (был удалён в R6):
- SupplierQuotaAllocator::distributeForPlatform(order, platforms) —
  largest-remainder, Σ долей == заказу (18→6/6/6, 10→4/3/3, 5→3/2).
- SyncSupplierProjectJob (online) + SyncSupplierProjectsJob (ночной):
  create / dead-donor / missing / update — по одной save на площадку с её долей.
  Online делит daily_limit_target; ночной делит групповой computeOrder.

Сторона выдачи клиенту не затронута (RouteSupplierLeadJob по-прежнему режет по
лимиту клиента). Утечка была только на стороне заказа у поставщика.

Tests: allocator 27/27, online job 9/9, nightly job 12/12, broad supplier
suite green. 2 SupplierPortalClient PlaywrightBridge-теста падают только в
worktree-окружении (нет node-модуля playwright) — pre-existing, доказано stash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:50:06 +03:00
Дмитрий 1dc696cef6 fix(supplier): перевод кодов регионов Лидерра→поставщик (конституционный→ГИБДД)
Лидерра нумерует субъекты по конституционному порядку (RussianRegions:
Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
Архангельск=29). Sync слал Лидерра-код как есть → поставщик выбирал ЧУЖОЙ регион
(заказчик выбрал Красноярский край — у поставщика встал Архангельск). На dev не
всплывало: проверяли на «вся РФ» (пустой regions).

Фикс: App\Support\SupplierRegions::mapToSupplier — карта 79 субъектов, построена
сверкой имён RussianRegions ↔ live-дерево формы «Добавить проект» поставщика
(recon 2026-05-21, node-key="id"). Перевод в единственной точке выхода —
SupplierPortalClient::toPayload (покрывает create/update/multiFlag). Тег остаётся
человекочитаемым именем Лидерры.

10 субъектов Лидерры поставщик не предлагает (Московская/Ленинградская/Крым/
Севастополь/ДНР/ЛНР/Запорожская/Херсонская/Ненецкий АО/ЯНАО) — их коды
отбрасываются с warning'ом (георфильтр для них у поставщика недоступен).

Тесты: SupplierRegionsTest (перевод/отброс/dedupe/биекция);
SupplierPortalClientRtProjectTest обновлён (regions [77]→[72] после перевода).

Проверено вживую на тест-сервере: проекты 14/15 пере-синхронизированы, доноры
12742042/12766120 у crm.bp-gr.ru → regions=24 (Красноярский), reverse=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:50:18 +03:00
Дмитрий b29bfe2ac6 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:49:59 +03:00
Дмитрий 83613b4509 fix(supplier): recreate deleted donor + fill legacy FK in online sync
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:21:42 +03:00
Дмитрий 07d73870ba refactor(projects): remove archive feature, drop archived_at column (schema v8.27) 2026-05-21 08:24:25 +03:00
Дмитрий 9d68fc0ad6 feat(supplier): delete/re-sync donor on project delete respecting sharing
DeleteSupplierProjectJob: если после удаления Лидерра-проекта у донора
(supplier_project) не осталось других потребителей (pivot
project_supplier_links) — удаляет его у поставщика и локально;
если потребители есть — НЕ удаляет, диспатчит SyncSupplierProjectsJob.
2 Pest-теста (no-consumers / remaining-consumers) GREEN.
phpstan-baseline: +once() Mockery chain (аналог andThrow baseline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 07:50:11 +03:00