Commit Graph

13 Commits

Author SHA1 Message Date
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 2706166f55 test(supplier): pivot-link AutoPause+Billing tests + redeclare guard (Plan 2 cascade) 2026-05-20 11:46:13 +03:00
Дмитрий 6b7f0035ef feat(supplier): LeadRouter eligibility via pivot, drop phone region filter 2026-05-20 11:26:40 +03:00
Дмитрий e401491947 feat(supplier): Plan 4 Task 4 — integrate LedgerService в RouteSupplierLeadJob + Task 3 carry-overs
Task 4 — integration:
- handle() / createDealCopyForProject() — +5-й параметр LedgerService.
- Заменён старый balance_leads-- + BalanceTransaction блок на
  \$ledger->chargeForDelivery(\$tenant, \$deal, \$lead) с try/catch для
  InsufficientBalanceException (Log::warning + rethrow; auto-pause flow
  в Task 6).
- LeadRouter::matchEligibleProjects — расширен фильтр tenant balance с
  (balance_leads > 0) на (balance_leads > 0 OR balance_rub > 0), чтобы
  rub-only tenant дошёл до LedgerService (single arbiter for dual-balance).
- 4 E2E теста в tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php:
  prepaid charge + BalanceTransaction (carry-over M-2), rub charge + BT,
  supplier_lead_costs gap-fix (2 deal-копии), retry idempotency.

Plan 4 Task 3 carry-overs (минорные правки по code-review d2030f9):
- I-2: PHPDoc на LedgerService::chargeForDelivery — @throws + @precondition
  (caller wraps в DB::transaction с lockForUpdate Tenant).
- I-4: trim() на raw_payload['project'] в resolveSupplierId (defense
  against whitespace).

Прочие правки:
- tests/Feature/Jobs/RouteSupplierLeadJobTest.php — +PricingTierSeeder
  в beforeEach + +5-й LedgerService параметр в runRouteJob().
- tests/Feature/Integration/SupplierLeadFlowTest.php — +PricingTierSeeder
  в beforeEach (test использует full webhook→job pipeline).
- tests/Feature/Services/LeadRouterTest.php — rename теста про balance_leads
  → \"zero in BOTH balance_leads AND balance_rub\" + новый тест
  \"rub-only tenant ДОЛЖЕН пройти\".
- phpstan-baseline.neon — +5 entries для TestCall::seed() + Tenant/LeadCharge
  property.notFound в новых файлах (IDE helper @mixin re-generation —
  отдельная задача).

Метрики: Pint clean, PHPStan 0 errors, Pest 646/643+3 skipped/0 failed
(21.1s parallel). Plan 4 Task 4 закрыт.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:06:38 +03:00
Дмитрий 8c70255d2b fix(supplier): Plan 3 Task 3 code-review fixes (4 Important + 3 Minor)
Закрывает 4 Important issues из code-review Task 3 (6d6181b):
- config/database.php: inline 11-key duplication заменён на single-source
  pattern через локальную переменную $pgsqlConnection (config() внутри
  config-файла не работает — Repository ещё не bootstrap'нут); 'pgsql' и
  'pgsql_supplier' теперь оба ссылаются на $pgsqlConnection; PDO options
  block с string-key _role_purpose удалён (PDO ждёт integer ATTR_* keys)
- tests/Concerns/SharesSupplierPdo.php (новый): trait для cross-connection
  PDO visibility в DatabaseTransactions; setUp override из TestCase.php
  удалён (был global на 562 теста, forced eager PDO connect);
  trait применён к 5 supplier-flow тестам: SupplierConnectionTest,
  LeadRouterTest, RouteSupplierLeadJobTest, ResetDeliveredTodayCommandTest,
  SupplierLeadFlowTest (все нуждаются в cross-connection видимости)
- phpstan-baseline.neon: entry для Pest TestCall->artisan() в
  SupplierConnectionTest заменён на inline @phpstan-ignore-next-line
  — local + self-documenting; добавлен baseline-entry для
  SharesSupplierPdo trait.unused (PHPStan не видит Pest uses() как trait usage)

Plus 3 Minor:
- typos 'dafault'/'corretly' (удалились с setUp override из TestCase.php)
- RouteSupplierLeadJob.php PHPDoc: \$connection → DB_CONNECTION консистентность

Pest: 562 tests, 560 passed + 2 skipped (без regression). PHPStan: 0 errors. Pint: clean.
2026-05-11 01:26:24 +03:00
Дмитрий 9540090bba fix(services): LeadRouter — Europe/Moscow timezone for workday-mask check
Code-review Important: Carbon::now() resolved against process TZ (UTC quirk
из memory). Reset cron в 00:00 МСК — mismatch вызвал бы off-by-one на
рубеже полуночи. Тест синхронизирован (now('Europe/Moscow')) — иначе
mismatch test/service near midnight. + комментарий про unreachable default
match arm (защищён на DB-уровне через chk_supplier_projects_platform).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:56:15 +03:00
Дмитрий 31929c9dd2 feat(services): LeadRouter — eligible Liderra projects matcher
Plan 2/5 Task 4 — sharing-model routing (spec §6): для входящего лида
возвращает Collection<Project> учитывая platform FK + active + workdays +
region (PhonePrefixService::phoneMatchesRegions) + delivered_today <
COALESCE(effective_daily_limit_today, daily_limit_target) +
tenant.balance_leads > 0. Сортировка created_at ASC, id ASC (детерминированно).

Параллельно расширил Project model fillable/casts на delivered_today
(колонка добавлена в schema v8.18 Plan 2 Task 1, но Project::class не
обновлён — без этого тесты Mass-Assignment'а ломались).

Покрытие: 9 it-blocks (sharing across tenants, paused, workdays, daily quota,
fallback to daily_limit_target, region filter, balance_leads zero, FK routing
по platform, deterministic sort). DatabaseTransactions context + set_config
(session-scoped) для очистки app.current_tenant_id — sharing-flow работает
поверх N tenant'ов, RLS bypass через postgres BYPASSRLS на dev.

PHPStan: 0 errors. Pint: clean. Pest: 9/9 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:50:28 +03:00
Дмитрий 001d7819bf fix(supplier): close code-review BLOCKER+WARN — FK + CHECK + resolver guard
Code-review subagent (CV.12 в Plan 1) нашёл 1 BLOCKER + 2 actionable WARNINGs:

1. **BLOCKER** — projects.supplier_b{1,2,3}_project_id были голыми BIGINT без
   REFERENCES, вопреки явному комментарию «FK добавятся в Task 2». Task 2
   создал supplier_projects, но FK на projects не вернул. Можно было записать
   произвольный BIGINT в эти колонки.
   Fix: ALTER TABLE projects ADD CONSTRAINT … FOREIGN KEY … ON DELETE SET NULL
   для всех трёх + 3 partial index (WHERE NOT NULL) для FK lookup.

2. **WARNING** (Project-level B1+SMS guard) — CHECK существовал только на
   supplier_projects; Project::create(['signal_type'=>'sms','supplier_b1_project_id'=>…])
   проходил вопреки spec §2.2 «B1 не поддерживает СМС».
   Fix: ADD CONSTRAINT chk_projects_b1_not_for_sms
   CHECK (signal_type <> 'sms' OR supplier_b1_project_id IS NULL).

3. **WARNING** (resolver collision) — SupplierProjectResolver::resolveOrStub
   firstOrCreate на (platform, unique_key) без signal_type → при коллизии
   unique_key возвращал чужую запись с другим signal_type без ошибки.
   Fix: после firstOrCreate проверяется match signal_type, иначе DomainException.
   +1 тест на collision.

Schema bumped v8.16 → v8.17. Метрики: 60 таблиц / 111 индексов (+3) / 39 RLS.
Pest: 500/498 passed (+1 collision test). Larastan 0 errors. Pint clean.

Spec: §2.1, §2.2
Plan: Task 2 (закрытие code-review CV.12)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:32:02 +03:00
Дмитрий 59c57f9ec0 feat(services): add SupplierProjectResolver (resolveOrStub with B1+SMS guard)
- app/Services/SupplierProjects/SupplierProjectResolver.php — резолвер
  по ключу (platform, signal_type, unique_key). Возвращает existing supplier_project
  или создаёт pending stub (физическая sync произойдёт в SyncSupplierProjectsJob, Plan 3).
- Защита 1: InvalidArgumentException на платформу не из {B1,B2,B3}.
- Защита 2: InvalidArgumentException на signal_type не из {site,call,sms}.
- Защита 3: DomainException на B1+SMS combo (chk_supplier_projects_b1_not_for_sms).
- tests/Feature/Services/SupplierProjectResolverTest.php — 6 тестов:
  resolve existing, create stub, idempotency (no duplicates), B1+SMS guard,
  invalid platform, invalid signal_type.

Pest: 475 / 473 passed / 2 skipped (467 + 6 новых = 473).
Larastan: 0 errors. Pint passed.

Spec: §2.2, §4.1
Plan: Task 11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:02:25 +03:00
Дмитрий eb8ca65c5d phase2(supplier-resolver): вынос дублированной логики lookup в App\Services\SupplierResolver
Закрыт TODO (a) из v1.49: общая логика выбора активного supplier'а через
project_suppliers m2m была дублирована между ProcessWebhookJob (webhook-flow)
и DealController (manual-create) — 11 одинаковых строк query-builder'а на
2 файла. Теперь — единственный источник истины + DI через app() (тот же
паттерн, что у DuplicateDetector в v1.23).

App\Services\SupplierResolver:
- resolveForProject(Project): ?int — точная копия прежней query
  (project_suppliers JOIN suppliers, is_active+is_active, ORDER BY
   sort_order, id).
- costRubSnapshot(int $supplierId): string — вынесенный snapshot цены
  для записи в supplier_lead_costs.

ProcessWebhookJob и DealController:
- Удалены private resolveSupplierId() (по 14 строк).
- Удалены локальные DB::table('suppliers')->value('cost_rub').
- Используют app(SupplierResolver::class) внутри handle()/store().

Pest +8 в tests/Feature/Services/SupplierResolverTest.php:
- null без связей / единственный активный / пропуск inactive supplier /
  пропуск inactive m2m / ORDER BY sort_order / null если все inactive /
  изоляция по project_id / costRubSnapshot формат '137.50'.

PHPStan baseline регенерирован.

Регресс:
- Pint + PHPStan passed (baseline регенерирован).
- Pest 174/174 за 21.46 сек (+8 от 166, 708 assertions).
- Vitest 247/247 за 17.53 сек (нетронут — backend-only refactor).

Реестр v1.58→v1.59 / CLAUDE.md v1.49→v1.50.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:11:44 +03:00