Commit Graph

80 Commits

Author SHA1 Message Date
Дмитрий b5849bbd2a fix(projects): cyrillic ILIKE via PG ICU + clearable workaround
Корень: dev-БД `liderra` создавалась с LC_CTYPE=C — lower()/upper() не
делает case-folding для кириллицы, `ILIKE '%сп%'` на «Окна СПб» = 0 строк.
Test-БД с Russian_Russia.1251 маскировала проблему.

Системный fix: dev-БД пересоздана через `LOCALE_PROVIDER icu ICU_LOCALE 'und'`
(PG 16+ ICU collation, кросс-платформенно). Точечный COLLATE-workaround не
понадобился — все 5 ILIKE-endpoint'ов теперь работают с кириллицей без
правки кода. CTO-20 закрыт в реестре v1.81; команда CREATE DATABASE с ICU
зафиксирована для prod-deploy.

Сопутствующее:
- ProjectsView clearable: workaround `::after content '✕'` + видимость
  через `.v-field--dirty` (mdi-* font не подключён в проекте — CTO-19
  заведён в реестре).
- LookupsTest: удалён stale case `GET /api/projects?tenant_id=N`,
  заменённый auth:sanctum-роутом в Plan 5.
- Pest +1 регрессионный тест (`search is case-insensitive for Cyrillic`)
  в ProjectsListShowTest, 10/10 / 37 assertions.
- phpstan-baseline регенерирован (3 actingAs + удалённый case).
- cspell-words: +Регистронезависимый, +und.
- app/.backups/ в gitignore.

Verify:
- Pest --parallel: 742 passed / 1 flaky error (CsvReconcileJobTest cache
  race, в изоляции 2/2 PASS) / 3 skipped.
- Browser: «сп» и «окн» возвращают «Окна СПб».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:25:25 +03:00
Дмитрий cb36a52171 test(projects-bulk): RLS cross-tenant isolation + empty-resolve edge case
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:02:04 +03:00
Дмитрий 64d8daede7 feat(projects-bulk): scope.filter resolver + 500-limit guard
Refactor inline scope resolution from ProjectController::bulk() into
ProjectService::resolveBulkScope (BULK_MAX=500 constant). Adds 2 tests:
scope.filter->ids mapping and >500 rejection (12 total, all pass).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:59:59 +03:00
Дмитрий c6eae16282 feat(projects-bulk): update_limit handler with per-project skip on delivered_today conflict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:55:45 +03:00
Дмитрий c025ec4b69 feat(projects-bulk): update_days handler with bitmask OR/AND-NOT
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:52:23 +03:00
Дмитрий 8220a85a5d feat(projects-bulk): update_regions handler with bitmask OR/AND-NOT
Refactor ProjectService::bulkAction to accept full payload array and
return structured {updated, skipped, warnings}. Add bulkUpdateRegions
using PG raw bitmask expr (region_mask | add) & ~remove & 255.
Add stubs for bulkUpdateDays/bulkUpdateLimit (Tasks 3-4). Update
controller to pass merged payload and return service result directly.
Un-todo Task-1 region validation test; add regions bitmask test (18/20).
Update phpstan-baseline: actingAs count 5->6, restore match.unhandled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:48:10 +03:00
Дмитрий 08f02100fe fix(projects-bulk): treat empty scope.filter as valid scope
Replace !empty() check with has()+is_array() so scope:{filter:{}} is
accepted as "all projects" rather than rejected as missing selection.
Expand scope.filter to IDs in the controller (500-row limit guard) so
the service receives a typed array[]; add Pest coverage for this case.
Update phpstan baseline count for new actingAs() call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:43:45 +03:00
Дмитрий 40202caf34 feat(projects-bulk): extend validation for 6 actions + scope
- BulkProjectActionRequest: add update_regions/update_days/update_limit actions, scope.filter, withValidator for ids-or-scope + delta/replace mutual exclusion
- ProjectBulkActionsTest: 4 new tests (3 pass, 1 todo pending Task 2 service handler)
- ProjectsActionsTest: update > 100 ids limit test to match new max:500
- phpstan-baseline: add 4 actingAs false-positive entries for new test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:38:59 +03:00
Дмитрий 458fa0b84d feat(projects): Plan 5 Task 6 — destroy + sync + toggle-active + bulk endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:06:07 +03:00
Дмитрий 6238b8b580 feat(projects): Plan 5 Task 5 — update + UpdateProjectRequest + resync trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:00:39 +03:00
Дмитрий 85f8e9e7a0 feat(jobs): Plan 5 Task 4 — SyncSupplierProjectJob full impl + ensureSupplierProject
- SyncSupplierProjectJob: replace stub with full implementation
  (tries=3, backoff=[15,60,300]s; resolvePlatforms uppercase B1/B2/B3;
  buildUniqueKey site/call→signal_identifier, sms B2→sender+keyword, B3→sender;
  column name via strtolower($platform) to match schema snake_case)
- SupplierPortalClient: drop final modifier (Mockery testability);
  add ensureSupplierProject() idempotent lookup-or-create wrapper
- Tests: 6 passing (site/call/sms-with-kw/sms-no-kw/exception/partial-failure);
  DI fix via dispatchJobSync() helper resolving mock from container;
  uppercase platform fixtures matching CHECK constraint B1/B2/B3;
  last_error column absent from schema — partial-failure test uses sync_status only
- phpstan-baseline.neon: add $this->mock() Pest TestCase inference gaps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:52:51 +03:00
Дмитрий 2ffbb49faa fix(projects): Plan 5 Task 3 code-review fixes (2 Important + 2 Minor)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:38:52 +03:00
Дмитрий 9d2e7270de feat(projects): Plan 5 Task 3 — store + StoreProjectRequest + ProjectService::create
- StoreProjectRequest: 3-way conditional validation (site domain regex, call 7\d{10}, sms senders required)
- ProjectService::create(): max_projects limit check via Tenant.limits JSONB + dispatch SyncSupplierProjectJob
- ProjectController: constructor DI + store() method returning 201
- SyncSupplierProjectJob: stub (Task 4 полная реализация)
- POST /api/projects route inside auth:sanctum+tenant group (name projects.store)
- Migration add_limits_to_tenants: JSONB DEFAULT '{}' per-tenant limits column
- Tenant model: limits added to fillable + casts as array
- schema.sql/CHANGELOG: tenants.limits documented in v8.20
- phpstan-baseline: +8 actingAs entries for new test file
- Quirk: region_mode in request uses 'include'/'exclude' (schema CHECK) not 'all'/'whitelist' (plan spec typo)
- Quirk: Project::first() → Project::where('signal_identifier','x.ru')->latest()->first() (no RefreshDatabase, persistent test DB)
- 8/8 ProjectsStoreTest passed; 699/706 total (4 pre-existing failures unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:29:54 +03:00
Дмитрий e242e7d7fc fix(projects): Plan 5 Task 2 code-review fixes (2 Important + 2 Minor)
I-1/M-1: introduce resolvedSupplierProjects() private helper on Project
model; rewrite aggregateSyncStatus(), aggregateLastSyncedAt(),
getSupplierLinks() to read from eager-loaded supplierB1/B2/B3 relations
instead of SupplierProject::find() — eliminates up to 120 SELECTs/page.

I-2: aggregateLastSyncedAt() now uses sortBy(timestamp) instead of
Collection::min() on Carbon objects (string-comparison was unreliable).

M-2: add explanatory comment on intval+array_filter silent-drop behaviour
in the ?ids batch-fetch path.

M-3: new test — ?ids batch silently excludes foreign-tenant project IDs.
M-4: new test — show returns 200 for archived project (read preserved).

PHPStan baseline updated: 2 new test functions raise actingAs() count 7→9.
Tests: 9/9 passed (33 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:15:36 +03:00
Дмитрий 35310b5517 feat(projects): Plan 5 Task 2 — index expanded (filters/search/pagination/ids) + show
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:08:01 +03:00
Дмитрий 174dbae808 feat(billing): Plan 4 Task 11 — TenantChargesController + ChargesTab + CSV export
Backend TenantChargesController:
- GET /api/billing/charges — paginated list, filters period (current_month / last_month / 90d) + charge_source.
- POST /api/billing/charges/export — StreamedResponse CSV (BOM + UTF-8) с chunkById(500).
- auth:sanctum + tenant middleware — RLS изолирует tenant_id.
- 6 Pest integration tests (RLS isolation + filters + pagination + CSV export).

Frontend ChargesTab.vue:
- v-data-table-server с paginated load + period/charge_source filters.
- CSV-download через blob → createObjectURL.
- Forest-palette + JetBrains Mono tnum.

BillingView.vue — добавлен tab «Списания» с импортом ChargesTab.
ChargesTab.story.vue + 4 Vitest tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:51:13 +03:00
Дмитрий 0f820c4569 feat(admin): Plan 4 Task 10 — AdminSuppliersController + AdminSupplierPricesView (B1/B2/B3 cost editor)
Backend AdminSuppliersController:
- GET /api/admin/suppliers — все 3 поставщика (B1/B2/B3).
- PATCH /api/admin/suppliers/{id} — обновляет cost_rub / quality_score / is_active.
- Validation: cost_rub >= 0, quality_score 0..9.99.
- Audit trail saas_admin_audit_log (stub admin via system-supplier@liderra.local).
- 4 Pest integration tests.

Frontend AdminSupplierPricesView (Vue 3 + Vuetify 3):
- v-data-table 3 строки с inline-editing cost_rub/quality_score/is_active.
- Forest-palette + JetBrains Mono tnum.
- 3 Vitest tests + Histoire story.

Router /admin/supplier-prices route.

Drive-by fix: SupplierProjectFactory.definition() default signal_type
ограничен ['site','call'] — иначе при ->create(['platform' => 'B1']) с
оригинальным random 'sms' нарушается CHECK chk_supplier_projects_b1_not_for_sms
(flaky parallel-pest race condition). Тесты, которым нужен 'sms', продолжают
явно передавать signal_type вместе с B2/B3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:28:03 +03:00
Дмитрий ed5e3f495d feat(admin): Plan 4 Task 9 — AdminPricingTiersController + AdminPricingTiersView (CRUD 7-tier + audit)
Backend AdminPricingTiersController:
- GET /api/admin/pricing-tiers — active + scheduled.
- POST — create 7-tier set с effective_from=DATE_TRUNC('month', NOW()+1 month).
- DELETE /scheduled/{date} — отмена будущей сетки.
- Validation: ровно 7 tier_no 1..7 unique, tier 7 leads_in_tier=null, price>=0.
- Audit trail saas_admin_audit_log на POST + DELETE (через SaasAdminAuditLog
  model: payload_before/after, NOT NULL admin_user_id резолвится через стаб
  system-pricing@liderra.local + ip_address из $request->ip()).
- 8 Pest integration tests.

Frontend AdminPricingTiersView (Vue 3 + Vuetify 3):
- v-data-table активной сетки + scheduled groups + dialog editor.
- Forest-palette + JetBrains Mono для tnum-цифр.
- 5 Vitest unit tests (tests/Frontend/, авто-импорт Vuetify через vite-plugin).
- Histoire story для preview.

Router /admin/pricing-tiers route (layout 'admin', requiresAuth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:18:01 +03:00
Дмитрий cb86065588 feat(supplier): Plan 4 Task 7 — SupplierCsvParser (streaming) + SupplierPortalClient::downloadLeadsCsv
SupplierCsvParser — pure streaming-generator CSV parser:
- BOM (UTF-8 EF BB BF) + CRLF normalization
- Malformed rows (< 6 columns) skipped + Log::warning
- 5 unit tests: empty / 1 row / 1000 rows / malformed / BOM+CRLF

SupplierPortalClient::downloadLeadsCsv(CarbonInterface, CarbonInterface):
- GET /admin/report/index?type=49 через существующий request() helper
- Наследует auth/retry семантику (401 → RefreshSession, 5xx → Transient, 4xx → Client)
- 3 unit tests через Http::fake: 200 / 401 retry / 500

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:50:11 +03:00
Дмитрий ce87936f44 feat(billing): Plan 4 Task 6 — auto-pause flow + ZeroBalancePausedMail + 1/hour rate-limit
При InsufficientBalanceException в LedgerService::chargeForDelivery:
- DB::transaction откатывается (Deal/charge/balance не тронуты).
- Outer catch в createDealCopyForProject вызывает handleInsufficientBalance:
  * UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS).
  * Email ZeroBalancePausedMail через NotificationService::notifyZeroBalancePaused.
  * Rate-limit 1/час/tenant через Redis SETNX (Cache::add).
  * Log::warning с tenant_id/project_id/balance details.
- Возвращаем false (не rethrow), чтобы handle()-loop продолжал routing остальным tenant'ам.

5 тестов: project paused / email sent / rate-limit 1/h / 2nd email after 65min /
sharing-flow isolation (A paused, B receives).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:43:51 +03:00
Дмитрий dadfdcaa7e feat(commands): Plan 4 Task 5 — ResetMonthlyCountersCommand + Schedule monthlyOn(1, 00:00) МСК
Месячный cron-сброс tenants.delivered_in_month + projects.delivered_in_month
1-го числа каждого месяца в 00:00 МСК. Идёт через pgsql_supplier BYPASSRLS
connection (паттерн ResetDeliveredTodayCommand). Идемпотентный
(WHERE delivered_in_month <> 0 → повторный запуск 0 affected rows).

4 теста: reset multi-tenant + idempotency + Schedule registration +
BYPASSRLS without SET LOCAL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:28:13 +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
Дмитрий d2030f9121 feat(billing): Plan 4 Task 3 — LedgerService::chargeForDelivery (dual-balance + lead_charges/supplier_lead_costs INSERT) 2026-05-11 09:42:29 +03:00
Дмитрий 1e0c0ab90a fix(billing): Plan 4 Task 2 code-review fixes (2 Important + 1 Minor)
- PricingTierResolver::resolveForCount — InvalidArgumentException на
  $leadOrdinal < 1 (closes I-1: defensive contract validation).
- PricingTierRepository::activeAt — explicit @var Collection<int,
  PricingTier> annotation для type narrowing (closes I-2; firstOrFail
  отвергнут — Stan ругается на Eloquent\Model return-type).
- PricingTierResolverTest — +1 unit test (8/8 PASS): throws на 0/-1.
- PricingTierRepositoryTest — +1 integration test (5/5 PASS): excludes
  inactive tiers (closes M-2 coverage gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:17:13 +03:00
Дмитрий e07d025efd feat(billing): Plan 4 Task 2 — PricingTierResolver + Repository (pure resolver + DB-обёртка)
- PricingTierResolver: pure-function, ищет tier для N-го лида (1-based).
  100→tier1, 101→tier2, 6000→tier6 (cumul.sum 1-6), 6001+→tier7 (NULL=unlimited).
  RuntimeException на пустой коллекции.
- PricingTierRepository::activeAt(Carbon): DB-обёртка, MAX(effective_from) <= $at
  per tier_no (учёт «новая сетка перекрывает старую»), is_active=true.
- 7 unit-тестов (in-memory, без БД) + 4 integration-теста (DatabaseTransactions
  с baseline-cleanup для seed-7-tiers из PricingTierSeeder Task 1).
- phpstan-baseline.neon: +3 entry (Pest TestCall::\$resolver/\$tiers/\$repo) —
  следуем project-convention (см. SupplierResolverTest идентичные baseline-entries).

Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
Plan: docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin.md Task 2

Tests: 633 passed / 3 skipped / 0 failed (+11 new); pint clean; stan 0 errors above baseline.
2026-05-11 09:05:22 +03:00
Дмитрий c52d42d447 fix(supplier): Plan 3 final review C-1 + I-2 — TestCase autowire + lock TTL
C-1: PlaywrightBridgeTest + RefreshSupplierSessionJobTest swap line order
- uses(TestCase::class) was BEFORE use Tests\TestCase → Pest resolved to
  non-existent \TestCase → file-load fatal → 6 tests NEVER ran since 71c999b.
- Fix: import BEFORE uses() call (pattern matches 3 working files в same dir).
- Misdiagnosis correction: ранее "pao stream_filter" symptoms attributed to
  laravel/pao env quirk были фактически C-1. Full suite parallel:
  617 tests / 614 passed / 3 skipped / 0 failed.

I-2: RefreshSupplierSessionJob lock TTL bump 30s → 90s
- PlaywrightBridge::TIMEOUT_SECONDS=75s. Lock TTL 30s → auto-expiry while
  real chromium boot+login still в процессе → concurrent refresh race.
- Fix: lock(name, 90)->block(95, ...) — exceeds PlaywrightBridge timeout.

Baseline regenerated после C-1 fix (stale entries removed, fresh Mockery
type errors captured).

Verification:
- tests/Unit/Supplier/: 30/30 pass (was 22/30 — 6 не запускались, 2 skip)
- Full parallel suite: 617/614+3 skipped/0 failed
- PHPStan: 0 errors. Pint: clean.

Final code-review (subagent verdict pre-C-1): "Ready to merge, with C-1 fix".
После этого commit'а — verdict satisfied.

Issues defer-to-post-merge per reviewer (follow-up tracking в memory):
- I-1 onOneServer (multi-server scaling; gated на Б-1 production)
- I-3 save_orphan recovery path (rare DB-write fail during HTTP success)
- I-4 region_mode='exclude' regions_reverse passthrough
- I-5 #4 acceptance criterion deferred until Task 1 closes
2026-05-11 06:51:22 +03:00
Дмитрий ecb6314e3b feat(supplier): Plan 3 Task 8 — RetryFailedSupplierJobsCommand + 5 Schedule entries
Components:
- supplier:retry-failed Console command (hourly cron):
  Re-dispatch RouteSupplierLeadJob для failed_webhook_jobs eligible
  (retried_at IS NULL OR < NOW()-1h; max-age guard via failed_at).

5 Schedule entries в routes/console.php:
- RefreshSupplierSessionJob hourly + dailyAt('20:15') МСК
- SyncSupplierProjectsJob dailyAt('20:30') МСК
- CleanupInactiveSupplierProjectsJob dailyAt('02:00') МСК
- supplier:retry-failed hourly

NB: ->onOneServer() НЕ применяется — нет cache_locks таблицы (см.
project_state фаза 1). Все операции идемпотентны.

+9 tests (subagent built per actual failed_webhook_jobs schema —
retried_at/retry_count columns). PHPStan baseline +21 Pest TestCall
+ property access entries (Mockery+Pest compat pattern).

Schedule verified via `artisan schedule:list`: 5 supplier entries listed
alongside existing projects:reset-delivered-today.
2026-05-11 06:46:13 +03:00
Дмитрий dedaae5aaa feat(supplier): Plan 3 Task 6 — SyncSupplierProjectsJob + SupplierQuotaAllocator
Компоненты:
- SupplierQuotaAllocator: pure function distribution-логики
  - site/call: B1=ceil(t/3), B2=ceil(r/2), B3=remainder
  - sms-with-keyword: B2+B3 only (B1=0, spec §2.2 — B1 не поддерживает СМС)
  - Workdays/regions union, weekday-фильтрация по Europe/Moscow
  - Возвращает null когда нет projects на targetWeekday
- SyncSupplierProjectsJob: 20:30 МСК cron
  - SupplierProject::on('pgsql_supplier') — cross-tenant видимость
  - whereNull('inactive_since') — sync только активные
  - Адаптер Project → stdClass: daily_limit_target → daily_limit,
    delivery_days_mask bits → workdays, region_mask bits → regions
    (mask=255 catch-all → regions=[])
  - per-supplier_project failure-isolation (continue на one bad)
  - mass-fail abort: 50 consecutive transient → SupplierCriticalAlertMail
    + Sentry + break
  - sticky auth → email('sticky_auth') + Sentry + throw
  - time budget cutoff 20:55 МСК (5-мин safety margin до 21:00)
  - supplier_sync_log per action (action='create'/'update', http_status,
    error_message)
- SupplierCriticalAlertMail: ShouldQueue Mailable + text template
  - Unisender Go SMTP relay через config('services.supplier.alert_email')

NOTE про connection: следуем Task 3 learning — не используем public \$connection
(это queue connection, не DB). Queries через Model::on('pgsql_supplier').

NOTE про DB::transaction: НЕ оборачиваем syncOne, т.к. HTTP-call к supplier
выходит за границы транзакции (атомарности всё равно нет). Два DB-write
последовательно; ошибка между ними recoverable через retry на следующем cron-tick
(supplier_external_id уже записан, скип через SupplierProjectDto::equals()).

+18 тестов (10 allocator + 8 sync job).

phpstan-baseline.neon: +7 entries для PHPStan template-covariance issue в
SupplierQuotaAllocatorTest — \`Collection<int, object{...literal}&stdClass>\` не
suptype \`Collection<int, stdClass>\` per PHPStan invariance rule. Production
code clean (0 baseline entries).
2026-05-11 06:46:13 +03:00
Дмитрий f298984055 feat(supplier): Plan 3 Task 5 — RefreshSupplierSessionJob + PlaywrightBridge
Компоненты:
- app/playwright/{package.json, refresh-session.js} — изолированный Node.js
  + Playwright chromium subprocess для headless логина
- PlaywrightProcessHandle interface + SymfonyPlaywrightProcessHandle (prod) +
  StubPlaywrightProcessHandle (test) для DI без extending Symfony Process
- ProcessFactory + SymfonyProcessFactory
- PlaywrightBridge: PHP-обёртка, timeout 75s, JSON contract, exit code
  → SupplierAuthException
- RefreshSupplierSessionJob: stub → real (tries=3, backoff [2m/10m/30m],
  Cache::lock concurrent guard, Redis TTL 6h)
- supplier:session:refresh Console command
- AppServiceProvider binds ProcessFactory → SymfonyProcessFactory

+7 tests (4 PlaywrightBridge + 2 Job + 1 Command).

NOTE: DOM-селекторы placeholder — финализация после Task 1 discovery.
NOTE: app/playwright/node_modules в .gitignore.

Quirks resolved:
- Mockery::mock(Process::class) + laravel/pao = stream_filter_remove fatal.
  Решение: handle interface, pure-PHP test stub без extends Process.
- PHPStan Mockery union types — baseline entries (known Mockery+PHPStan compat).

KNOWN LIMITATION: на этой Windows машине pao stream filter conflict при
serial run SupplierPortalClient+RefreshSupplierSessionJob combo.
Tests pass individually + парами. Production Linux CI не affected.
2026-05-11 06:46:13 +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
Дмитрий 6d6181b8cc feat(supplier): Plan 3 Task 3 — switch supplier-flow на pgsql_supplier (BYPASSRLS)
Закрывает 3 backlog-айтема Plan 2.6 одной правкой:
- BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL теперь проходит
  (BYPASSRLS обходит RLS-политику отвергавшую NULL под обычной ролью)
- WARN #2: LeadRouter::matchEligibleProjects видит projects всех tenant'ов
  через Project::on('pgsql_supplier') без SET LOCAL app.current_tenant_id
- WARN #3: ResetDeliveredTodayCommand обновляет projects всех tenant'ов
  через DB::connection('pgsql_supplier')

Архитектура: crm_supplier_worker BYPASSRLS-роль (создана Plan 2.6 #iv 7899071)
+ новый pgsql_supplier connection в config/database.php. WHERE(tenant_id=)
фильтры сохраняются как defense-in-depth.

Уточнение по Job's $connection: оригинальный план предполагал public $connection
= 'pgsql_supplier' на RouteSupplierLeadJob, но в Laravel Job's $connection
управляет очередью (sync/database/redis), не БД. Заменено на константу
RouteSupplierLeadJob::DB_CONNECTION + явный DB::connection(self::DB_CONNECTION)
в failed() callback'е. Это:
1) не ломает queue resolution (без этой правки тесты падают
   'pgsql_supplier queue connection has not been configured')
2) явно документирует intent — failed_webhook_jobs INSERT идёт через BYPASSRLS
3) handle()'s tenant-scoped транзакции остаются на default pgsql + SET LOCAL,
   где RLS нужна для defense-in-depth.

Также добавлено в tests/TestCase.php разделение PDO между pgsql и
pgsql_supplier connection'ами через setPdo/setReadPdo — иначе DatabaseTransactions
не откатывал бы supplier-side данные (две PDO-сессии = две независимые транзакции,
supplier не видит uncommitted INSERTs default-side).

Brainstorm decision: вариант C из 3 опций (A=schema bump, B=отдельная таблица,
C=BYPASSRLS-role). См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.

+4 теста в Feature/Supplier/SupplierConnectionTest.php (DB_CONNECTION constant +
BLOCKER#6 + WARN#2 + WARN#3). 0 schema changes.

Pest: 562/560 + 2 skipped (baseline 558/556 + 4 new = 562/560, ok). PHPStan: 0 errors
(добавлен 1 baseline entry для известного Pest+PHPStan limitation на artisan()).
Pint: clean.
2026-05-11 01:00:47 +03:00
Дмитрий 451a2944f7 fix(http): timestamp validation ±24h для partition guard (Plan 2.6 #iii)
Закрывает CV.11 audit WARN minor #5 (Carbon::createFromTimestamp(time) без
range guard → INSERT CRASH "no partition of relation deals found for row"
для timestamp вне текущего месячного окна deals_2026_MM).

Изменение: SupplierWebhookController::receive — добавлено min/max constraint
на 'time' = [now-24h, now+24h] unix-timestamp. Timestamp вне окна → 422
ValidationException.

±24h: покрывает retry-задержки поставщика (network-сбой) + clock-drift серверов;
шире окно (±48h+) = риск partition-промаха на стыке месяцев (нужен Plan 5
partition cron).

TDD: +3 теста (-2 days → 422; +2 days → 422; -6h → 202).

Regression-fix: existing test 'inserts supplier_lead row' использовал hardcoded
'time' => 1703781939 (Dec 28 2023) — теперь out-of-window. Заменено на time().

phpstan-baseline: postJson() count: 8 → 11 (+3 от Task 3 тестов).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:11:26 +03:00
Дмитрий f78a85595c fix(http): IP allowlist fail-closed в production env (Plan 2.6 #ii)
Закрывает CV.11 audit WARN #5 (пустой supplier_ip_allowlist '[]' = fail-open
на production — любой IP пропускается).

Изменение: SupplierWebhookController::verifyIpAllowlist — пустой allowlist
возвращает true только если env != production. На production пустой allowlist
блокирует (404). На dev/testing fail-open сохраняется (для localhost development).

TDD: +2 теста (production env empty → 404; testing env empty → 202).
Inline-warning header обновлён.

phpstan-baseline: count: 6 → 8 (postJson() Pest TestCall PhpDoc-quirk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:08:16 +03:00
Дмитрий e71a02e498 feat(commands): supplier:check-webhook-secret deploy validator (Plan 2.6 #i)
Закрывает CV.11 audit WARN #4 (placeholder secret '__SET_ON_DEPLOY__' = silent
404 на production через verifySecret в SupplierWebhookController).

Console command для deploy-script: SELECT system_settings.supplier_webhook_secret
→ exit 1 если placeholder OR len < 32 OR row отсутствует. Иначе exit 0.

Использование: deploy-script вызывает `php artisan supplier:check-webhook-secret`
перед запуском приложения; non-zero exit прерывает deploy fail-fast.

TDD: 4 теста (placeholder rejected / short rejected / missing rejected / valid accepted).
phpstan-baseline +1 entry: Pest TestCall::artisan() PhpDoc-quirk (как
ResetDeliveredTodayCommandTest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:04:32 +03:00
Дмитрий 9daa94d917 feat(commands): projects:reset-delivered-today (00:00 МСК cron)
Spec §6.1: ежедневный сброс projects.delivered_today=0 после midnight МСК.
delivered_in_month НЕ трогаем (это месячный счётчик, Plan 4 cron).

Реализация: Artisan-команда `projects:reset-delivered-today` (idempotent
UPDATE без транзакции/локов — отрабатывает за <1 сек), Schedule в
routes/console.php с dailyAt('00:00')->timezone('Europe/Moscow').

NB: `withoutOverlapping()` пропущен — требует таблицу cache_locks, которой
нет в schema.sql (Laravel-default-миграции удалены в фазе 1). Идемпотентность
UPDATE делает overlap-защиту избыточной.

Tests: 2/2 pass, phpstan 0 errors (1 baseline для $this->artisan, как у
прочих Pest-тестов с artisan-helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:58:47 +03:00
Дмитрий b6b5b0bc1f test(integration): supplier webhook → N deals end-to-end (sharing-model)
Полный сценарий: 1 webhook на B1_vashinvestor.ru → 3 deal-копии у 3 активных
tenant'ов + счётчики + balance. Paused tenant пропущен. Orphan supplier_project
создаёт stub, processed_at установлен, deals_created_count=0.

Запуск через Laravel sync queue (default test env) — без Bus::fake().

Spec §5-§6 e2e validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:43:44 +03:00
Дмитрий e41c8f5aef feat(http): SupplierWebhookController — platform-wide /api/webhook/supplier/{secret}
Defense-in-depth: secret (≥32 chars system_setting) + IP allowlist (CIDR).
Несовпадение → 404. UNIQUE vid → 200 OK на дубль (idempotency).

Тесты пока FAIL (route регистрируется в Task 7 — пишем "красные" тесты заранее
для TDD-цикла).

Spec §5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:38:18 +03:00
Дмитрий f77c91d5fa feat(backend): Sprint 4 Phase A — keyset pagination в DealController::index (audit O-perf-04)
Опциональный query-параметр `cursor` (base64-encoded JSON {r:received_at, i:id}).

При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`
с использованием существующего индекса (received_at DESC, id DESC).
O(1) на любой глубине, без COUNT(*) (total не возвращается в keyset-режиме).

Без cursor — backward-compat OFFSET-путь: total + offset для существующего
frontend. Оба режима возвращают next_cursor (NULL = последняя страница).

Trick "+1 fetch" — узнаём про следующую страницу одним SELECT'ом без COUNT.

3 новых Pest-теста: keyset-навигация через cursor, 422 на невалидный cursor,
next_cursor flow. Pest 421/421 (419 + 2 skipped browser, +3 от 418 baseline).

phpstan-baseline.neon регенерирован: +2 occurrences pattern
`received_at?->toIso8601String()` (cursor build) + 7 occurrences тестовых
helper-properties Pest TestCall — все известные ignored patterns, не реальные ошибки.

Frontend integration в useDealsList/DealsView — отдельным шагом
(не блокирует backend deploy, OFFSET путь жив).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:29:54 +03:00
Дмитрий f9d8926945 phase2(admin-tenant-detail-backend): GET /api/admin/tenants/{subdomain} с 4 секциями
- AdminTenantsController +show($subdomain): возвращает tenant base + users +
  projects + balance_history + activity + computed metrics (leads_today/week/
  month, avg_lead_cost_rub, runway_days). Lookup по subdomain (естественный
  URL slug) + whereNull deleted_at. Без auth-middleware (saas-admin SSO ⏸ Б-1).
- 4 private fetch'ера + computeMetrics:
  - fetchUsers: ORDER last_active_at DESC, LIMIT 50, поля email/first/last/
    is_active/totp_enabled/last_active_at/last_login_at.
  - fetchProjects: LEFT JOIN sub-queries для suppliers_count + leads_today
    (deals в текущем дне). Поля name/tag/is_active/daily_limit_target.
  - fetchBalanceHistory: ORDER created_at DESC, LIMIT 30. Поля type/amount_rub/
    amount_leads/balance_rub_after/description/created_at.
  - fetchActivity: LEFT JOIN users (actor_email), LIMIT 20. context json_decode.
  - computeMetrics: один SELECT с FILTER для leads counts; AVG cost_rub за
    30 дней; runway_days = balance / (month_spend / 30).
- routes/web.php: GET /api/admin/tenants/{subdomain} where [a-z0-9_-]+.
- Pest +13 в AdminTenantShowTest.php (всего 416/416, +13 от 403, 1388 assertions):
  404 unknown / 404 soft-deleted / базовые поля / 4 секции + metrics keys в response /
  users изоляция / projects suppliers_count + leads_today / balance_history ORDER+LIMIT 30 /
  balance_history изоляция / activity actor_email LEFT JOIN (user + system events) /
  metrics leads_today/week/month / metrics runway_days computed / tariff_name+mrr_rub /
  mrr_rub null для trial.
- phpstan-baseline регенерирован.

Этап A эпика AdminTenantDetailView (backend) закрыт. Этап B: frontend
integration + Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:32:24 +03:00
Дмитрий 9765ed760d phase2(reports-stage3): retry/cancel/destroy + reports:cleanup-expired cron
- ReportJobController +3 endpoints под auth:sanctum:
  - POST /api/reports/jobs/{id}/retry — CTO-6: только owner+failed, max 3 попытки
    (parameters.retry_count), окно 7 дней с created_at, квота CTO-7 учитывается;
    создаёт НОВЫЙ ReportJob (parameters.retry_of=original.id) + dispatch.
  - POST /api/reports/jobs/{id}/cancel — только owner+pending; status=failed +
    error_message=«Отменено пользователем.» + finished_at=NOW.
  - DELETE /api/reports/jobs/{id} — только owner+terminal (done|failed); удаляет
    файл из disk('local') + row.
- toResource +3 поля: is_expired (expires_at < NOW), retry_count, retry_max=3.
- App\Console\Commands\ReportsCleanupExpired (cron `reports:cleanup-expired`):
  где status='done' AND expires_at < NOW AND file_path IS NOT NULL → delete file
  + UPDATE file_path=NULL. CTO-10: status='done' СОХРАНЯЕТСЯ. failed-jobs
  игнорируются. --dry-run + --limit=1000. Запуск ежесуточно через Task Scheduler.
- routes/web.php: новые 3 routes под существующим prefix /api/reports/jobs.
- Pest +21 в ReportLifecycleTest.php (всего 403/403, +21 от 382, 1343 assertions):
  retry 8 (404 unknown/foreign / 403 не владелец / 422 не failed / success+new+
  retry_count=1+retry_of / 422 max retries / 422 окно 7 дней / 422 квота 3) +
  cancel 4 (404 / 422 не pending / success / 403 не владелец) + destroy 5
  (404 / 422 pending / 403 не владелец / success+file / success+file_path=NULL)
  + index +1 (is_expired/retry_count/retry_max в response) + cron 3 (удаление
  expired+CTO-10 status сохраняется / --dry-run / failed игнорируются).
- phpstan-baseline регенерирован.

Этап 3/4 эпика Reports backend (закрыт). Этап 4: frontend integration —
заменить mock в ReportsView на реальный API + UI кнопки retry/cancel/delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:44:09 +03:00
Дмитрий 1a6a74c1a0 phase2(reports-stage2): provider+formatter архитектура + XLSX/JSON/PDF-stub
- Реструктура Services/Reports: вместо `Generator` per (type×format) комбинации
  (16 классов) разделено на 4 Providers + 4 Formatters (8 классов).
- App\Services\Reports\Providers\ReportDataProvider interface + DealsExportProvider
  (вынесен из старого DealsExportCsvGenerator; возвращает headers + rows).
- App\Services\Reports\Formatters\ReportFormatter interface + 4 реализации:
  - CsvFormatter — Excel-friendly (BOM + ; + \r\n + escape).
  - XlsxFormatter — PhpSpreadsheet 5.x (A1-нотация + bold headers + auto-size cols).
  - JsonFormatter — pretty + UNESCAPED_UNICODE (кириллица в исходном виде).
  - PdfStubFormatter — Post-MVP, throw RuntimeException.
- ReportGeneratorRegistry перепаспортирован: provider(type) + formatter(format).
- GenerateReportJob: вызывает provider->headers/rows + formatter->format вместо
  старого generator->generate.
- Удалено: DealsExportCsvGenerator, ReportGenerator interface, GenerationResult DTO.
- Pest +3 (всего 382/382, +3 от 379, 1297 assertions): xlsx → done с XLSX-magic-bytes
  PK\x03\x04; json → done + decoded ['rows', 'headers']; pdf → failed «Post-MVP»;
  managers_summary (не реализован) → failed.
- phpstan-baseline регенерирован.

Этап 2/4 эпика Reports backend (закрыт). Этап 2b: 3 оставшихся типа провайдеров
(managers_summary / sources_summary / billing_summary) — каждый × 4 формата без
изменений в архитектуре. Этап 3: retry/cancel/delete + retention cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:39:32 +03:00
Дмитрий 19f319cd5d phase2(reports-stage1): ReportJob model + GenerateReportJob + API + deals_export csv
- ReportJob Eloquent (schema §13.5 report_jobs): status pending/processing/done/failed,
  parameters JSONB (format/date_from/date_to/project_id?/manager_id?), constants
  TYPES + FORMATS, helpers isActive/isDone/isFailed.
- ReportJobFactory + states processing/done/failed.
- App\Services\Reports\* пакет: ReportGenerator interface, GenerationResult DTO,
  ReportGeneratorRegistry с резолвом по (type,format), DealsExportCsvGenerator
  (Excel-friendly CSV: BOM, ; separator, \r\n, escape; deals JOIN projects/users/
  supplier_lead_costs за date_from..date_to, soft-deleted скрыты).
- App\Jobs\GenerateReportJob: tries=1 (auto-retry отключён, retry через UI кнопку
  CTO-6); меняет status pending → processing → done|failed, заполняет file_path/
  file_size/generation_seconds/finished_at/expires_at (=NOW+30д).
- App\Http\Controllers\Api\ReportJobController под auth:sanctum:
  - GET /api/reports/jobs?status=&limit=&offset= → jobs+total+counts+quota
  - GET /api/reports/jobs/{id}
  - POST /api/reports/jobs (квота CTO-7: max 3 active per tenant → 422)
  - dispatch GenerateReportJob (sync на dev → файл готов сразу).
- Storage local-disk на dev (storage/app/reports/{tenant_id}/{job_id}.csv);
  на prod заменим на s3 (Yandex Object Storage) отдельным коммитом.
- Pest +20 в tests/Feature/Reports/ReportJobControllerTest.php (всего 379/379,
  1280 assertions): 401 без auth / GET пустой+only-own+ORDER+filter+counts+limit/
  show success+404 own/foreign / store 422 (без полей/неизвестный type/date_to<from)/
  dispatch / sync queue → done с file (BOM проверен) / unsupported format → failed/
  квота 3 → 422 на 4-м / квота не считает done+failed / квота per-tenant.
- phpstan-baseline регенерирован (+1 ignored для Factory typing).

Этап 1/4 эпика Reports backend (закрыт). Этап 2: 4 типа × 4 формата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:34:03 +03:00
Дмитрий 39b6127bce phase2(reminders-backend): CRUD + cron-диспетчер + email/inapp (P0 этап 4)
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.

Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
  GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
    окно ±1 день, counts для UI badges);
  POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
  PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
  POST {id}/complete (idempotent);
  DELETE {id}.
  RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
  TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
  ?? created_by (если active+!deleted). Каналы email+inapp по prefs.
  payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
  {--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
  LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
  ставит is_sent=true даже если recipient deactivated (защита от retry-spam).

Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
  store + FK guard / update text+remind_at сбрасывает is_sent / complete
  idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
  skip / уже sent / assignee вместо created_by / deactivated user (is_sent
  всё равно) / только inapp при email=false / --dry-run / --limit / RLS.

PHPStan baseline регенерирован. IDE-helper для всех моделей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:30:38 +03:00
Дмитрий f55b91cfa4 phase2(notifications-stage3): NotificationsTab schema-aligned + prefs API
Закрывает архитектурное расхождение v1.28 — Tab сохранял prefs только
локально без API. Backend events не совпадали с handoff'ом.

Backend:
- PATCH /api/auth/me/notification-preferences под auth:sanctum.
- Replace-семантика: незадекларированные events/channels отбрасываются.
- userResource расширен: notification_preferences + sound_enabled.
- UserFactory с schema-default JSON (Eloquent не перечитывает после INSERT,
  DB-DEFAULT JSONB виден как null без явного override).
- Pest +10: 401 / replace / неизвестные events/channels отбрасываются /
  422 без prefs / sound_enabled опционален / bool-cast 1/'1' / replace-
  семантика (отсутствующие events исчезают).

Frontend:
- api/auth.ts: типы NotificationChannel/EventKey/Preferences +
  updateNotificationPreferences helper. AuthUser получил optional поля.
- NotificationsTab.vue переписан под schema:
  8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/
  invoice_paid/new_device_login/marketing) × 3 канала (inapp/push/email,
  НЕ sms). Sync-init prefs (без onMounted — иначе v-if блокирует рендер
  и тесты mount-then-find падают). dirty через computed-сравнение с
  originalPrefs snapshot. save async + success/error alerts.
- SettingsView.spec.ts: legacy event-имена → schema-aligned.
- Vitest +10: 8 schema events / 3 channels (НЕ sms) / legacy отсутствуют /
  читает prefs из user / save calls API + alerts / Отменить возвращает.

cspell-words: +prefs.
PHPStan baseline регенерирован.

Pest 315/315 (+10) за 36.73 сек, 1130 assertions.
Vitest 349/349 (+10) за 20.42 сек.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:41:35 +03:00
Дмитрий 508de4eaf3 phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).

Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
  GET /api/notifications?unread_only=&limit= (1..100 default 50);
  PATCH /api/notifications/{id}/read (idempotent);
  POST /api/notifications/mark-all-read (bulk + count);
  DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).

Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
  optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
  pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
  v-list последних 10 из sortedItems с event-icon + formatRelative;
  Mark-all-read btn только при unreadCount > 0;
  click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
  (bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
  на mount с user / no-op без user).

PHPStan baseline регенерирован (50 Pest false-positives подавлены).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:27:57 +03:00
Дмитрий 7f5ff874a8 phase2(notifications-stage2a): in_app_notifications + notifyInApp (schema v8.10)
P0 этап 2a — backend-фундамент bell-icon канала. UI bell + API endpoints
— этап 2b отдельным коммитом.

Schema v8.9 → v8.10:
- Новая таблица in_app_notifications (после reminders в schema):
  id/tenant_id/user_id/event/title/body/deal_id/payload/read_at/created_at.
- 2 индекса: unread (user_id, created_at DESC) WHERE read_at IS NULL
  + recent (user_id, created_at DESC).
- RLS tenant_isolation.
- Метрики: 55→56 таблиц, 93→95 индексов, 36→37 RLS.
- CHANGELOG_schema.md +§T.

Backend:
- App\Models\InAppNotification — Eloquent с UPDATED_AT=null.
- NotificationService::notifyInApp — INSERT через DB::transaction + SET
  LOCAL app.current_tenant_id для RLS. Throwable + Log::warning.
- notifyNewLead шлёт два канала параллельно: email + inapp.

Pest +11 (291/291 за 32.94 сек, 1060 assertions):
- inapp=true/false; schema-default (inapp=true в схеме);
- 2 user'а получают / inactive не получает / RLS изоляция;
- дубль Биз-19 / повторный vid / оба канала / payload deal_id;
- notifyInApp напрямую с reminder.

PHPStan baseline регенерирован. IDE-helper для InAppNotification.
cspell-words: +inapp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:17:39 +03:00
Дмитрий a4601fe84b phase2(notifications-stage1): NotificationService + new_lead email (P0 этап 1)
Старт closing «Notification delivery» из карты P0. Этап 1/6 плана:
NotificationService + Mailable + интеграция в ProcessWebhookJob::chargeNewLead.

- App\Services\NotificationService — диспетчер 8 событий × 3 каналов
  (inapp/push/email) согласно schema.sql:699 users.notification_preferences.
  Этап 1 реализует только email-канал для new_lead.
- App\Mail\NewLeadNotification + emails/new_lead.blade.php — HTML-письмо
  в Forest-палитре с таблицей phone/contact_name/received_at/deal_id.
- ProcessWebhookJob::chargeNewLead — после ActivityLog вызывает
  notifyNewLead. Throwable от Mail::send проглатывается + Log::warning
  (отказ канала не должен валить транзакцию).
- Pest 11/11 в tests/Feature/Notifications/NewLeadNotificationTest.php:
  email=true получает / email=false не получает / schema-default не шлёт /
  inactive не получает / soft-deleted не получает / другой тенант не
  получает / Биз-19 дубль не дублирует / повторный vid не дублирует /
  balance=0 не шлёт / subject содержит project_name.
- IDE-helper регенерирован (4 модели получили @mixin docblocks).
- PHPStan baseline регенерирован (138 ignore.unmatched схлопнулись).

Pest 280/280 за 31.27 сек (+11 от 269, 1029 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:03:43 +03:00
Дмитрий 830a652588 phase2(trash-bin): GET /api/deals?only_deleted + «Корзина» в DealsView
Расширяет stages 5/6 (soft-delete + 8-сек undo) до постоянного доступа
к удалённым сделкам через отдельный view-mode.

Backend (DealController::index):
- Новый query-param only_deleted=true.
- withTrashed() + whereNotNull('deleted_at') — обход global scope
  SoftDeletes + явный фильтр для NO-OP idempotency.
- Все остальные фильтры применимы и в trash-mode.

Pest +3 (DealIndexTest):
- only_deleted=true → только soft-deleted (alive скрыты).
- Без only_deleted → soft-deleted скрыты (default behavior).
- RLS+app-фильтр изолирует чужие удалённые.

Frontend:
- ListDealsParams.onlyDeleted?: boolean + axios mapping.
- DealsView: trashMode ref + toggleTrashMode (clear selected + reload) +
  applyBulkRestoreFromTrash (optimistic remove + bulkRestoreDeals + toast).
- UI changes в trash-mode:
  - Заголовок «Сделки» → «Корзина».
  - Toggle-btn 'mdi-arrow-left К сделкам' (warning-flat) вместо
    'mdi-trash-can-outline Корзина' (outlined).
  - Скрыты Экспорт + Новая сделка.
  - Скрыт chiprow filter-bar.
  - Info-alert «Корзина: показаны удалённые сделки».
  - Bulk-bar: только Восстановить (mdi-restore success-tonal) + clear;
    status/export/delete скрыты.

Vitest +2 (DealsListIntegration):
- toggleTrashMode → trashMode=true + listDeals с onlyDeleted=true.
- applyBulkRestoreFromTrash → bulkRestoreDeals + remove from state +
  toast «Восстановлено 2».

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

Регресс:
- Lint+type-check+format passed.
- Vitest 321/321 за 19.60 сек (+2 от 319).
- Vite build 1.04 сек.
- Pint + PHPStan passed.
- Pest 269/269 за 29.12 сек (+3 от 266, 1009 assertions).

Реестр v1.72→v1.73 / CLAUDE.md v1.63→v1.64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:27:11 +03:00
Дмитрий fa11c7b223 phase2(admin-tenants-mrr): mrr_rub в /api/admin/tenants (этап 7)
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null.
Теперь AdminTenantsView показывает реальную колонку MRR.

Backend (AdminTenantsController::index):
- Добавлено tariff_plans.price_monthly as tariff_price_monthly в select.
- mrr_rub в response: price_monthly (string) если не-trial; иначе null.
- Aggregate-формат как у /admin/billing — string чтобы decimal не терял
  точность при передаче через JSON.

Pest +3 (AdminTenantsIndexTest):
- mrr_rub='990.00' для активного тарифа не-trial.
- mrr_rub=null для trial (даже если тариф есть).
- mrr_rub=null если current_tariff_id отсутствует.

Frontend:
- ApiAdminTenant.mrr_rub: string | null в типе.
- mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded
  null из v1.66).
- AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими
  ₽-полями.

Vitest +2:
- mrr_rub строка → number.
- mrr_rub=null → mrrRub null.

PHPStan baseline регенерирован. cspell-glossary +консистентности.

Регресс:
- Lint+type-check+format passed.
- Vitest 313/313 за 18.83 сек (+2 от 311).
- Vite build 947 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion).

Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:08:12 +03:00
Дмитрий c34d4009d1 phase2(restore-flow): POST /api/deals/restore + undo-snackbar (этап 6 — completion of stage 5)
Soft-delete был half-done: пользователь не мог отменить случайное удаление.
Теперь после bulk-delete показывается snackbar «Удалено N · Восстановить»
на 8 секунд.

Backend (DealController::restore):
- POST /api/deals/restore {tenant_id, ids: [1..1000 ints]}.
- withTrashed() обходит global scope SoftDeletes + явный
  whereNotNull('deleted_at') для NO-OP idempotency на живых.
- RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.restored, context.source='bulk' для каждой
  ВОССТАНОВЛЕННОЙ. Константа EVENT_DEAL_RESTORED добавлена в модель.

Pest +7 (DealRestoreTest):
- 422/404 базовые / soft-delete + restore + audit / NO-OP на живых
  не пишет audit / defense-in-depth (свой restored, чужой остался) /
  после restore видна в GET /api/deals / 422 пустой массив.

Frontend:
- dealsApi.bulkRestoreDeals — POST-helper.
- DealsView::applyBulkDelete: snapshot удалённых сделок (deep-clone
  manager.*) сохраняется в lastDeletedSnapshot ref.
- undoBulkDelete() async: optimistic re-insert + bulkRestoreDeals если
  auth.user; success → toast «Восстановлено N»; fail → warning.
- v-snackbar bulk-delete: 3→8 сек timeout + #actions слот с кнопкой
  «Восстановить» (показ только при snapshot.length > 0). После undo
  snapshot очищается → кнопка пропадает.

Vitest +3 (DealsListIntegration):
- bulk-delete + undo восстанавливает обе + bulkRestoreDeals + cleanup
  snapshot.
- Undo без tenant_id — НЕ вызывает API + только локально.
- Undo reject → warning toast + локальное восстановление остаётся.

PHPStan baseline регенерирован. cspell-glossary +unshift +партиальный.

Регресс:
- Lint+type-check+format passed.
- Vitest 311/311 за 18.71 сек (+3 от 308).
- Vite build 877 ms.
- Pint + PHPStan passed.
- Pest 263/263 за 27.68 сек (+7 от 256, 998 assertions).

Реестр v1.69→v1.70 / CLAUDE.md v1.60→v1.61.

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