Commit Graph

341 Commits

Author SHA1 Message Date
Дмитрий 720697ae43 style(audit): pint auto-fix на shared config + rebuild rewrite
Task 6 Step 4 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Pint auto-fix purely cosmetic (unary_operator_spaces, phpdoc_align,
ordered_imports, fully_qualified_strict_types, no_blank_lines_after_phpdoc).
Никаких semantic-изменений.

Larastan analyse --level=max на 3 файла: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:41 +03:00
Дмитрий 307a65e786 test(audit): drop pre-rebuild sanity-check в multi-tenant test
Test env (`SharesSupplierPdo` trait + postgres superuser) обходит RLS, поэтому
trigger `audit_chain_hash()` в тестах пишет global chain, не per-tenant. Это
расхождение с prod (где RLS активен и trigger пишет per-tenant) валидно — но
делает pre-rebuild sanity-check невыполнимым assumption'ом.

Multi-tenant test теперь проверяет только self-consistency post-rebuild:
rebuild должен produce chain matching своему partition_clause.

Pre-Task-4 (global LAG): post-rebuild verify с PARTITION BY tenant_id → mismatch
→ RED (текущее состояние).

Post-Task-4 (per-tenant LAG): post-rebuild verify с PARTITION BY tenant_id →
match → GREEN.

Prod RLS-aware trigger semantics валидируется live `audit:verify-chains`, не в
этом тесте.

Ref: docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:39 +03:00
Дмитрий 88cdd34e98 test(audit): failing tests для per-tenant rebuild (ADR-018, RED phase)
Task 3 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
3 новых сценария в AuditRebuildChainTest.php:

1. multi-tenant — 2 tenants, 4 rows interleaved, rebuild from firstId →
   chain должна остаться intact per-tenant. RED: fails на pre-rebuild
   sanity-check (preMismatches=1) — в test env trigger пишет НЕ per-tenant
   chain (SharesSupplierPdo trait → BYPASSRLS). Task 4 имплементер должен
   разобрать: либо trigger в test env починить (RLS-aware), либо тест
   адаптировать к фактической семантике pgsql_supplier.

2. BYPASSRLS auth_log — INSERT direct через pgsql_supplier, partition_clause=''
   (global chain within partition). Сейчас PASS случайно (single global LAG
   совпадает с tенущим rebuild semantics).

3. single-row partition — 1 tenant, 1 row, rebuild → должна работать.
   Сейчас PASS случайно.

+ new const AUTH_LOG_ROW_EXPR mirror'ит AuditChainConfig::TABLES['auth_log'].

Регрессия narrow: 7 tests / 6 passed / 1 failed (RED expected).

Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
     docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:39 +03:00
Дмитрий 52eebe28c5 refactor(audit): VerifyAuditChains использует shared AuditChainConfig (ADR-018)
Task 2 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Regression-safe refactor: drop private TABLE_CONFIG const + buildRowExpression()
helper, заменить на чтение AuditChainConfig::TABLES (создан в Task 1, commit
4cfd9f6b) + AuditChainConfig::rowExpression($table). Поведение не изменилось —
тот же baseline regression Pest (9 passed pre-refactor → 10 passed post-refactor;
+1 = регрессия-guard VerifyAuditChainsTest.php flipped fail→pass; 2 pre-existing
errors orthogonal к Task 2).

VerifyAuditChainsTest.php — TDD regression guard на cleanness рефактора: проверяет
полноту AuditChainConfig::TABLES (6 таблиц), корректность rowExpression() для
всех таблиц, и отсутствие private TABLE_CONFIG const после refactor'а.

Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
     docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:38 +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
Дмитрий c5c7e284e1 feat(exceptions): reduce verbosity для constraint violations (SQLSTATE 23xxx)
ремонт: incident 29.05 cause — 420k stack traces в laravel.log = 8.7 GB

Adds reportable() handler что для QueryException с SQLSTATE 23xxx (integrity
constraint violations) пишет 1-line warning summary вместо default error report.

3 Pest tests cover: 23505 unique → warning, 42P01 non-constraint → error preserved,
23514 check_violation → warning.

Effect: 420k violations × 35KB stack = 14.7 GB → 420k × 200B warning = 84 MB.
175× reduction in log volume during constraint-violation storm.

NB: LEFTHOOK_EXCLUDE=deptrac,larastan because pre-existing violations не от
этого изменения. User-approved bypass; separate PR will address deptrac.yaml
+ IDE helper regeneration + larastan baseline.

Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
2026-05-29 14:14:04 +03:00
Дмитрий b502db8fdc feat(audit): add audit:rebuild-chain command for race-condition recovery
Replays sha256 chain in given audit partition from given id:
1. Uses pgsql_supplier (BYPASSRLS) to see all rows regardless of RLS scope.
2. Bypasses audit_block_mutation trigger via session_replication_role=replica
   (session-local SET, does not affect other connections).
3. Recomputes hash per row using the same formula as audit_chain_hash():
   digest(COALESCE(prev_hash,''::bytea) || ROW(col1,...,NULL::bytea,...,coln)::text::bytea, 'sha256')
   Column order from COLUMN_CONFIG matches TABLE_CONFIG in VerifyAuditChains.
4. Supports --dry-run (count without UPDATE) and --force (skip confirmation).

Validated against breaking partitions:
  --partition=activity_log_y2026_m05 --from-id=599
  --partition=balance_transactions_y2026_m05 --from-id=462

Tests: 4 tests — activity_log rebuild, balance_transactions rebuild,
dry-run no-op, unknown partition rejection. All pass (4/4 GREEN).

Refs: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:20:57 +03:00
Дмитрий 15df5b4a46 test(audit): failing test for audit_chain_hash race condition
Two tests:
1. pcntl_fork concurrent-INSERT test (skipped on Windows/no pcntl) —
   demonstrates chain branch when 5 workers insert into the same partition
   simultaneously; passes after advisory-lock migration.
2. pg_locks advisory lock presence test (Windows-compatible) —
   verifies that pg_advisory_xact_lock is actually held in pg_locks
   during an INSERT transaction, proving the migration works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:20:55 +03:00
Дмитрий 84620665a5 feat(incidents): single-lead-storm detection in incidents:watch-failures
Добавлен БЛОК 5 в IncidentsWatchFailures::handle() — детекция шторма от
одного supplier_lead_id. Если один lead_id генерирует >= threshold-single-lead
failures за окно (default=1000) → severity=high инцидент с root_cause
'single-lead-storm:<lead_id>'. Дедуп по dedup-window как в остальных блоках.

Новая опция: --threshold-single-lead=1000 (configurable).

Мотивация (Finding 2 Stage 5, 2026-05-29): supplier_leads 1110+1157 генерировали
~256k строк в failed_webhook_jobs за 24ч без алерта. Этот блок создаёт incident
уже при 1000+ failures одного лида в 10-минутном окне — что позволяет обнаружить
шторм в течение первого часа.

Связь с Task 2 (fast-fail): вместе эти два изменения stop new storms (Task 2)
и alert on remaining storms (Task 3).

Tests: 4 passing в SingleLeadStormTest.php
- детекция шторма (>= threshold)
- НЕ создаёт incident при распределённых failures
- default threshold=1000
- dedup (второй запуск = 0 новых инцидентов)

Task 3 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
Дмитрий 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
Дмитрий f6072b2885 feat(billing): R-19 — share-aware requiredLeadsForTomorrow
Tenant::requiredLeadsForTomorrow() previously summed raw daily_limit_target of
active projects, overcharging preflight when a tenant shared a call/site signal
with other tenants. Supplier caps the group at max(max(limits), ceil(Σ/3)) and
splits it across all clients on the same signal_identifier, so a single tenant's
real share is typically much smaller than its raw limit.

  group_limits = limits of all is_active projects sharing
                 (signal_type, agnostic signal_identifier/sms_sender+keyword)
  group_order  = max(max(group_limits), ceil(Σ group_limits / 3))
  tenant_share = ceil(group_order × (project_limit / Σ group_limits))

Legacy webhook projects (signal_type=null — no supplier sharing) still count
their full limit (regression-protected by existing 'sums daily_limit_target' test).
Empty groupLimits edge → conservative full-limit fallback (cross-conn race).

3 Pest tests: single project (legacy passthrough), 3-tenant share discriminator
(10→4), legacy webhook regression. Stage 4 §4.4.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:05:53 +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
Дмитрий a823518bb7 feat(billing): R-13 — sync paused_at on freeze/unfreeze transitions
Stage 3 Task 3.2. BalancePreflightSweepJob now mirrors freeze/unfreeze state
onto projects.paused_at so SupplierSnapshotGuard has the right hook to block
delete/change_source while the supplier slepok tail can still arrive:

- On freeze: capture freezeAt = now() once, set tenant.frozen_by_balance_at
  AND projects.paused_at (only WHERE paused_at IS NULL) to the same moment.
  This gives the snapshot guard a uniform recent paused_at across all of the
  tenant's projects.
- On unfreeze: capture frozen_at_was BEFORE save, then clear paused_at only
  on projects whose paused_at >= frozen_at_was (== auto-paused by us).
  Manual pauses set by the client BEFORE freeze have paused_at < frozen_at_was
  and stay preserved.

Spec §4.3.2.
2026-05-28 15:39:27 +03:00
Дмитрий 36d7fd1923 feat(billing): R-03 — LedgerService rejects frozen tenants
Stage 3 Task 3.1. Add frozen_by_balance_at guard in chargeForDelivery() before
bcmath arithmetic. Even if balance_rub > 0, a tenant flagged by
BalancePreflightSweepJob must not be charged for new lead deliveries. The
InsufficientBalanceException throw triggers the existing auto-pause flow
(RouteSupplierLeadJob::handleInsufficientBalance → projects.is_active=false +
ZeroBalancePausedMail rate-limited). Spec §4.3.1.
2026-05-28 15:33:36 +03:00
Дмитрий bf48bde5ca fix(lead-router): R-03 — exclude frozen tenants from eligible matches
Stage 3 Task 3.0. Add 'AND tenants.frozen_by_balance_at IS NULL' to both
EXISTS-on-tenants subqueries in matchEligibleProjects (DIRECT path + B path).
Without this filter, a tenant frozen by BalancePreflightSweepJob continues to
receive leads from the existing slepok, getting charged for deliveries they
explicitly cannot fund. Spec §4.3.1 R-03.
2026-05-28 13:41:42 +03:00
Дмитрий dd5954d8a5 feat(slepok): Task 2.11 (backend slice) — ProjectResource serializes applies_from
ProjectResource теперь включает поле `applies_from` (ISO8601 строка | null) в
JSON-ответе. Установлен ProjectService::update() для slepok-sensitive правок
(Task 2.8 dynamic attribute).

UI Vue/composables/Vitest часть откладывается на отдельную сессию — это
backend-only commit для бэкенд-инструмента UI-сообщения.

Spec §4.2.5.

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

Tests: tests/Feature/Http/Resources/ProjectResourceAppliesFromTest.php — 2/2 PASS.
2026-05-28 07:02:49 +03:00
Дмитрий 6d6fa10d91 feat(slepok): Task 2.10 — snapshot:rebuild artisan command for fail-loud recovery
Manual recovery после падения SnapshotProjectRoutingJob cron'а. В отличие от
snapshot:backfill (ON CONFLICT DO NOTHING), snapshot:rebuild сначала DELETE'ит
существующий snapshot за дату, затем INSERT'ит свежий из live state.

Fail-loud strategy (Spec §4.2.6):
  1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4 — already wired).
  2. LeadRouter Log::error on missing snapshot (Task 2.5 — already wired).
  3. Manual recovery: php artisan snapshot:rebuild --date=YYYY-MM-DD.

NO fallback to live projects — explicit downtime + alert is safer than silent
regression.

NB: ->transaction() wrapper НЕ используется — конфликтует с SharesSupplierPdo
shared-PDO в тестах. half-done state допустим: retry восстанавливает; на проде
admin контроль и редкость вызова.

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

Tests added:
- tests/Feature/Console/SnapshotRebuildCommandTest.php — 2 tests.

Status: RED locally (Windows-native PG Project factory signal_type quirk —
same as Task 2.2/2.3, memory project_slepok_protection.md). Command itself
registered (php artisan list | grep snapshot). GREEN expected on CI Linux.
2026-05-28 07:01:41 +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
Дмитрий 19644a1d36 feat(slepok): Task 2.8 — ProjectService exposes applies_from after slepok-sensitive update
ProjectService::update() теперь возвращает Project с dynamic applies_from
attribute (CarbonImmutable | null), который ProjectResource подхватит для UI
(«изменения вступят в силу с DD.MM 21:00»).

Логика: для каждого изменённого поля из SupplierSnapshotGuard::SLEPOK_SENSITIVE_FIELDS
вычисляется максимум appliesFrom() — slepok-инвариант (до 18:00 МСК = today 21:00,
после = tomorrow 21:00). NULL = применяется немедленно (none changed / no supplier links).

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

Tests: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php — 4/4 PASS.
ProjectService regression — 7/7 PASS.
2026-05-28 06:49:43 +03:00
Дмитрий 83e0cab8cb feat(slepok): Task 2.7 — SupplierSnapshotGuard::appliesFrom() API
Возвращает CarbonImmutable когда правка slepok-sensitive поля вступит в силу:
  правка до 18:00 МСК → сегодня в 21:00 МСК
  правка с 18:00 МСК и позже → завтра в 21:00 МСК

Возвращает null когда правка применяется немедленно:
  - поле не slepok-sensitive (вне 7 полей SLEPOK_SENSITIVE_FIELDS), либо
  - проект не связан с поставщиком (нет project_supplier_links)

7 slepok-sensitive полей: is_active, daily_limit_target, delivery_days_mask,
regions, signal_identifier, sms_senders, sms_keyword.

Spec §4.2.5. Используется ProjectService (Task 2.8) для прикрепления к
UI-ответу метки «изменения вступят в силу с DD.MM HH:MM МСК».

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

NB plan-bug: оригинальные тесты в плане использовали Project::factory()->make()
с id=null, что приводило к WHERE project_id IS NULL → 0 совпадений. Заменил
на ->create() для реального id (factory default signal_type=null nullable в
projects table, не блокирует create()).

Tests added:
- tests/Feature/Services/Project/SupplierSnapshotGuardAppliesFromTest.php
  (11 tests including dataset-driven для 7 полей, 11/11 isolated PASS).
2026-05-28 06:43:48 +03:00
Дмитрий 050e271d51 feat(slepok): Task 2.6 — RouteSupplierLeadJob snapshot lock + is_active recheck (R-04/R-06/R-09)
createDealCopyForProject теперь:
1. После lockForUpdate(Project) проверяет live is_active — если paused между
   matchEligibleProjects и handle, return false (не доставляем под lock).
2. Читает snapshot.daily_limit под lockForUpdate(snapshot row) за активную
   дату слепка (до 21:00 МСК = today, после = today+1). delivered_today
   сравнивается с snapshot.daily_limit, не с live daily_limit_target.
3. После $project->increment('delivered_today') атомарно инкрементит
   snapshot.delivered_count — для CSV business-drift reconcile.

Closes R-04 (auto-pause каскад прерывается под lock'ом), R-06 (уменьшение
лимита после слепка не блокирует уже-зафиксированный поток), R-09 (race
recheck under lockForUpdate).

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

Tests added:
- tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php (2 tests, GREEN locally).

Combined Task 2.5+2.6 targeted regression: 52/52 GREEN.
2026-05-28 06:32:55 +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
Дмитрий 7eac4b33db feat(slepok): Task 2.3 — snapshot:backfill artisan command
One-time use at Stage 2 deploy + manual recovery if cron fails.
Idempotent via ON CONFLICT (snapshot_date, project_id) DO NOTHING.

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

Tests: tests/Feature/Console/SnapshotBackfillCommandTest.php (2 tests).
Status — same as Task 2.2: RED locally on Windows-native PG test env
(Project factory signal_type override does not persist — both create([...])
and asCallSignal() state-method tried; both produce NULL in INSERT). GREEN
expected on CI Linux per memory project_slepok_protection.md.
2026-05-27 15:18:26 +03:00
Дмитрий 87336f74dc feat(slepok): Task 2.2 — SnapshotProjectRoutingJob daily snapshot
Daily 18:02 MSK job: captures eligible projects state into
project_routing_snapshots for tomorrow date. Filters frozen tenants,
preflight_blocked projects, weekday_mask. Carries effective_daily_limit_today
(R-11/OPEN-5 var A). Idempotent via INSERT ON CONFLICT DO NOTHING.

Spec section 4.2.2.
2026-05-27 11:07:47 +03:00
Дмитрий 662be183db feat(schema): project_routing_snapshots partitioned table + MonthlyPartitionManager entry (Task 2.1, Slepok routing Etap 2)
- migration 2026_05_27_120000: CREATE TABLE project_routing_snapshots PARTITION BY RANGE (snapshot_date)
  composite PK (snapshot_date, project_id), FK tenant_id->tenants ON DELETE CASCADE
  RLS policy tenant_isolation, indexes tenant_date + signal
  GRANT crm_app_user (SELECT/INSERT/UPDATE), crm_supplier_worker (+DELETE)
  initial partitions y2026_m05 + y2026_m06
  system_settings retention 3m
- MonthlyPartitionManager::PARTITIONED_TABLES +'project_routing_snapshots' => 'snapshot_date'
- db/schema.sql -> v8.39
- tests: ProjectRoutingSnapshotsTableTest (3) + Unit/MonthlyPartitionManagerTest (1) GREEN

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:56:08 +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
Дмитрий bb22c8325d fix(lead-router): R-12 — remove balance_leads from eligibility filter
balance_rub is the only balance used after Spec A Phase A.
LeadRouter SQL still referenced legacy balance_leads in OR clause —
would crash on Spec B Phase B DROP COLUMN. Filter now only checks balance_rub.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:58:23 +03:00
Дмитрий fea1443b18 feat(billing-v2-c): online supplier sync на freeze/unfreeze (привязка к SupplierExportMode)
При переходе active→frozen или frozen→active BalancePreflightSweepJob теперь дёргает SyncSupplierProjectJob per-project, если admin-переключатель в режиме online. В batch (рабочем для будущего масштаба) — sync отложен до cut-off cron 18:00 MSK через SyncSupplierProjectsJob.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:23 +03:00
Дмитрий e1601e7862 feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:21 +03:00
Дмитрий fe4a409480 feat(billing-v2-c): one-time billing:preflight-initial-sweep
Task 1.9 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

Разовая artisan-команда для запуска при выкатке Spec C — прогоняет
BalancePreflightSweepJob по всем тенантам, замораживает legacy-
тенантов в минусе. Идемпотентна (sweep-job triggers только на
active↔frozen переходах, стабильное состояние не трогает).

TDD: 1 тест GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:20 +03:00
Дмитрий fd877ab156 feat(billing-v2-c): ProjectController preflight — 409 при перегрузке баланса
Task 1.7 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

store/update проверяют преfflight перед созданием/изменением проекта:
- если сумма daily_limit_target всех активных не-blocked проектов
  превышает capacity баланса (через BalancePreflightService) и не
  передан force_save_blocked=true → возврат 409 с JSON-телом:
  {error, current_balance_rub, current_capacity_leads,
   would_be_required_leads, deficit_leads}
- если force_save_blocked=true → проект создаётся/обновляется с
  preflight_blocked_at=now() (точечная заморозка одного проекта,
  не блокирует остальные).

Safe fallback: без активных pricing_tiers — преfflight skipped
(legacy-окружения без настроенного биллинга).

TDD: 4 теста GREEN (409 store / 409 update / force_save_blocked
создаёт blocked / norm pass через capacity).

Регрессия: 0 регрессий на Plan5 ProjectsStoreTest+ProjectsUpdateTest
(37/37 GREEN после safe fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:19 +03:00
Дмитрий 787df436a3 feat(billing-v2-c): повторные письма заморозки (reminder +1д, final +3д)
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final).
Throttle через balance_freeze_log markers (event_type 'reminder_sent' /
'final_sent') на 5 дней — повторов в окне не будет.

Re-evaluate PreflightResult для актуального дефицита в письме
(клиент мог частично пополнить — reminder покажет обновлённое число).

Schedule @18:30 MSK (после основного sweep @18:00) — если sweep
только что заморозил тенанта, reminder в тот же день не сработает
(окно 24h+ ещё не открыто).

TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:18 +03:00
Дмитрий df12ac6757 fix(billing-v2-c): per-tenant Mail-фильтр в idempotent-тесте sweep-job
liderra_testing persistent (RefreshDatabase off) — DemoSeeder тенанты
могут попасть в sweep и тоже получить BalanceFrozenMail. Без per-tenant
фильтра Mail::assertNotQueued() ловил 154 фоновых письма и валил тест.

Логика BalancePreflightSweepJob корректна — фикс только в test isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:17 +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
Дмитрий 53f9020653 feat(billing-v2-c): sweep-job заморозки + 4 mailable + cron 18:00 MSK
Task 1.4+1.5 Спека C. BalancePreflightSweepJob (chunkById всех тенантов,
переход active->frozen / frozen->active, идемпотентность, журнал balance_freeze_log
через pgsql_supplier) + BillingPreflightSweepCommand + cron billing:preflight-sweep
@18:00 MSK (SyncSupplierProjectsJob сдвинут 18:00->18:05). 4 Mailable
(Frozen/Reminder/Final/Unfrozen) + blade. Job шлёт Frozen/Unfrozen при переходах;
Reminder/Final (T+24h/T+72h) — классы готовы, рассылка по дате — следующий шаг.
11 Phase 1 billing-тестов GREEN. Адаптации под факт схемы: contact_email (не email),
organization_name (не name), is_active+daily_limit_target (не status+daily_limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:15 +03:00
Дмитрий b1c3f39e38 feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки
Task 1.3 Спека C. Tenant: +frozen_by_balance_at (fillable+cast datetime) +
requiredLeadsForTomorrow() (sum daily_limit_target активных проектов). Project:
+preflight_blocked_at (fillable+cast). NB: фильтр по is_active (boolean) +
daily_limit_target — у projects нет колонок status/daily_limit (план поправлен
под факт схемы). 3 теста GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:14 +03:00
Дмитрий 0da72778c3 fix(supplier): Phase 2 merge — не обновлять deals.received_at (FK violation)
Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с
SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить
deals.received_at:

    update or delete on table "deals_y2026_m05" violates foreign key
    constraint "lead_charges_deal_id_deal_received_at_fkey"
    on table "lead_charges"

Корневая причина: lead_charges имеет FK на (deal_id, deal_received_at)
с ON DELETE CASCADE, но ON UPDATE NO ACTION (default Postgres). Phase 2
merge (commit 8d037e1f) условно обновлял deals.received_at, если webhook
пришёл позже CSV-recovered. Любое изменение received_at ломало FK даже
в той же месячной партиции (DEFERRABLE INITIALLY DEFERRED только
откладывал проверку до COMMIT — она всё равно падала).

Фикс: убрать условное обновление received_at, оставить только
source_crm_id + updated_at. CSV-recovered timestamp сохраняется как
есть — отличие на минуты несущественно vs риск каскадного DELETE
lead_charges.

Тест: tests/Feature/Jobs/RouteSupplierLeadJobTest.php — новый
'merges webhook into csv-recovered deal even when received_at differs'
воспроизводит баг (CSV-recovered deal с lead_charge → webhook с другим
received_at → merge должен пройти без FK violation).

NB: локальный verify-RED заблокирован env-drift testing-БД
(auth_log partitions via pgsql_supplier, см. memory). Прод-смок:
реретрай застрявших failed_jobs 25489+25492..25500 → должны пройти.

Affected failed_jobs (для реретрая после деплоя):
  25489, 25492, 25493, 25494, 25495, 25496, 25497, 25498, 25499, 25500

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:39:33 +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
Дмитрий b92d9b3bfc test(supplier-webhook): assert JSON 422 for non-JSON Accept clients (failing)
Reproduces 302-redirect bug observed on prod 2026-05-25 — when supplier
crm.bp-gr.ru POSTs without Accept: application/json, Laravel renders
ValidationException as redirect to /, losing body. Test calls webhook
without Accept header and asserts JSON 422 response. Will fail until
bootstrap/app.php has render(ValidationException) for api/webhook/supplier/*.
2026-05-25 17:37:44 +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
Дмитрий 0817c81e67 fix(admin): снять 503-замок saas-admin зоны — защиту держит nginx basic-auth
EnsureSaasAdmin fail-closed 503 вне dev/testing → вся админка на боевом
liderra.ru недоступна (все /api/admin/* падали). Настоящий saas-admin SSO
(Yandex 360) ещё не готов (Б-1 + DO-4), но держать зону наглухо закрытой
нельзя — заказчику нужна админка.

Стопгэп (выбор заказчика): защита /admin + /api/admin/* переносится на
nginx (отдельный HTTP Basic Auth, /etc/nginx/.htpasswd-admin), middleware
зону больше не закрывает. Тест production-кейса переведён с 503 на 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:35:03 +03:00
Дмитрий af690eaaaa refactor(webhook): Phase 5 — delete SupplierWebhookLoggingTest (tests dropped webhook_log table)
SupplierWebhookLoggingTest.php queried webhook_log table which was dropped
in Phase 4 DROP migration (schema v8.35). This file was missed in Phase 3
cleanup (WebhookReceiveTest.php was deleted but SupplierWebhookLoggingTest
was a separate file testing the same dropped infrastructure).

4 tests deleted — all tested webhook_log INSERT/SELECT which is now gone.
SupplierWebhookTest.php (new controller tests) remains unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:18 +03:00
Дмитрий 6e1f5355b8 refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.

Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).

Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)

Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token

Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35

Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54

Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:17 +03:00
Дмитрий e5eed0aeac refactor(webhook): Phase 3 Task 3.1 fixup - delete WebhookReceiveTest.php (missed in Task 3.1+3.2 commit) 2026-05-24 18:51:15 +03:00