Commit Graph

166 Commits

Author SHA1 Message Date
Дмитрий 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
Дмитрий f0dce283a8 phase2(soft-delete): schema v8.9 + DELETE /api/deals (этап 5/5 — авто-план закрыт)
Bulk soft-delete для UI applyBulkDelete. Hard-delete отбракован из-за
CASCADE-FK от webhook_dedup_keys: hard уничтожил бы dedup-ключи и
нарушил идемпотентность webhook §5.5.

Schema v8.8 → v8.9:
- deals.deleted_at TIMESTAMPTZ (NULL = живая).
- Partial index (tenant_id, status) WHERE deleted_at IS NULL —
  самый частый UI-фильтр.
- ALTER TABLE на партиционированной deals distributes во все 6
  партиций автоматически (PG 14+).
- CHANGELOG +§U с обоснованием soft vs hard.

Backend (DealController::destroy):
- DELETE /api/deals {tenant_id, ids: [1..1000 ints]}.
- Bulk-update deleted_at=NOW() через RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.deleted (source='bulk') для каждой ИЗМЕНЁННОЙ.
- NO-OP (уже удалена) не пишет audit.
- Deal model: SoftDeletes trait + deleted_at в fillable/casts. Global
  scope автоматически добавляет whereNull('deleted_at') ко всем существующим
  query (index/show/transition/update/export).

Pest +8 (DealDestroyTest):
- 422/404 базовые / soft-delete + audit / defense-in-depth (свой
  удалён, чужой жив) / NO-OP idempotency / GET скрывает soft-deleted
  (list+show 404) / 422 пустой массив.
- Quirk: migrate:fresh --env=testing без .env.testing использует liderra
  вместо liderra_testing → решение DB_DATABASE=liderra_testing migrate:fresh.

Frontend:
- dealsApi.bulkDeleteDeals — DELETE-helper с config.data (axios особенность).
- DealsView::applyBulkDelete async: optimistic local-removal +
  bulkDeleteDeals если auth.user; success → toast «Удалено N из M.»;
  fail → warning toast + локальный update НЕ откатывается.

Vitest +3 (DealsListIntegration):
- bulkDeleteDeals с tenant_id + optimistic + toast.
- Без tenant_id — НЕ вызывается.
- Reject → warning toast + локальный update остаётся.

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

АВТО-ПЛАН (5 этапов) ЗАКРЫТ ПОЛНОСТЬЮ.

Регресс:
- Lint+type-check+format passed.
- Vitest 308/308 за 20.12 сек (+3 от 305).
- Vite build 973 ms.
- Pint + PHPStan passed.
- Pest 256/256 за 27.75 сек (+8 от 248, 977 assertions).

Реестр v1.68→v1.69 / CLAUDE.md v1.59→v1.60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:51:47 +03:00
Дмитрий 14dc317e2b phase2(admin-incidents): GET /api/admin/incidents + AdminIncidentsView API (этап 4/5)
Чтение incidents_log с фильтрами type/severity/unresolved_only + summary
(open/investigating/rkn_pending/total_unresolved).

Backend (AdminIncidentsController::index):
- ORDER BY started_at DESC. Filters: type, severity, unresolved_only=true.
- Derived: incident_id (INC-YYYY-MMDD-NNNN), status (resolved_at!=null →
  resolved; detected_at!=null → investigating; иначе open),
  affected_tenants_count из BIGINT[] (parsePgArray для '{1,2,3}'),
  rkn_deadline_at = detected_at+24h для data_breach без notification.
- summary: open/investigating/rkn_pending/total_unresolved.

Pest +11 (AdminIncidentsIndexTest):
- пустой / incident_id формат / derive status / filter type+severity /
  unresolved_only / ORDER BY started_at DESC / rkn_deadline +24h для
  data_breach / non-data_breach без deadline / summary.rkn_pending /
  limit+offset.
- Quirk: saas_admin_users.full_name (не first/last) + нет updated_at.

Frontend:
- api/admin.ts::listAdminIncidents — типизированный helper.
- AdminIncidentsView: унифицированный IncidentRow (mock-category ↔
  API-type, mock-title ↔ API-summary). Reactive rowsState+stats default
  = MOCK; loadIncidents() async на onMounted; fetchError + warning
  alert + MOCK fallback; reload-btn. РКН pending chip учитывает оба
  pdn_breach/data_breach.

Vitest +5:
- listAdminIncidents на mount / replace state+stats + rkn_deadline /
  reject → fetchError+alert+fallback / reload-btn x2 / РКН pending chip
  виден для data_breach без notification.

PHPStan baseline регенерирован. cspell-glossary +MMDD.

Регресс:
- Lint+type-check+format passed.
- Vitest 305/305 за 20.59 сек (+5 от 300).
- Vite build 1.05 сек.
- Pint + PHPStan passed.
- Pest 248/248 за 28.02 сек (+11 от 237, 951 assertion).

Реестр v1.67→v1.68 / CLAUDE.md v1.58→v1.59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:38:34 +03:00
Дмитрий 4532b95d64 phase2(admin-billing): GET /api/admin/billing + AdminBillingView API (этап 3/5)
Aggregates пополнений/списаний за текущий месяц по balance_transactions
+ summary с MRR/revenue/overdue/refunds_30d.

Backend (AdminBillingController::index):
- GET /api/admin/billing?search=. Per-tenant SUM с CASE WHEN type IN
  ('topup','lead_charge') GROUP BY tenant_id; ABS для charges.
- Row: id/subdomain/organization_name/contact_email/status/balance_rub/
  tariff_id/tariff_name/mrr_rub (=tariff.price_monthly если не-trial)/
  monthly_topups_rub/monthly_charges_rub/last_payment_at/
  chargeback_unrecovered_rub.
- summary: total_mrr_rub (SUM не-trial), monthly_revenue_rub (SUM topup),
  overdue_count (balance<0 || chargeback>0), refunds_count_30d.
- Quirk: schema-колонка tariff_plans.price_monthly (НЕ price_rub_monthly)
  — обнаружено первым прогоном Pest, исправлено сразу.

Pest +9 (AdminBillingIndexTest):
- пустой / поля+tariff JOIN / aggregates за месяц / прошлый месяц не
  попадает / overdue / refunds_30d (старые исключены) / total_mrr_rub
  (trial исключаются) / search ILIKE / soft-deleted скрыт.

Frontend:
- api/admin.ts::listAdminBilling — типизированный helper.
- AdminBillingView: reactive rowsState+summary default = MOCK,
  loadBilling() async на onMounted парсит API-строки → numbers + derive
  status (suspended/balance<0||chargeback>0→overdue/active). На fail —
  fetchError + warning alert + MOCK fallback. Reload-btn.
- tariffLabel/statusInfo обобщены с fallback'ами на новые slug'и.

Vitest +4:
- listAdminBilling на mount / replace rowsState+summary + string→number
  + status derive / reject → fetchError+alert+fallback / reload-btn x2.

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

Регресс:
- Lint+type-check+format passed.
- Vitest 300/300 за 18.41 сек (+4 от 296).
- Vite build 925 ms.
- Pint + PHPStan passed.
- Pest 237/237 за 27.69 сек (+9 от 228, 926 assertions).

Реестр v1.66→v1.67 / CLAUDE.md v1.57→v1.58.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:28:49 +03:00
Дмитрий 6ef9961f5f phase2(admin-tenants): GET /api/admin/tenants + AdminTenantsView API (этап 2/5)
AdminTenantsView переходит с mock-данных на live backend.

Backend (AdminTenantsController::index):
- GET /api/admin/tenants?status=&search=&limit=&offset=.
- LEFT JOIN tariff_plans для tariff_name. ORDER BY last_activity_at DESC.
- ILIKE search по organization_name + subdomain + contact_email.
- stats {total, active, trial, overdue} — overdue считает balance<0
  ИЛИ chargeback_unrecovered_rub > 0.
- На MVP без auth (saas-admin SSO ⏸ Б-1).

Pest +8 (AdminTenantsIndexTest):
- 200 + пустой / все поля / status filter / search ILIKE /
  ORDER BY last_activity_at DESC / stats / soft-deleted скрыт /
  limit+offset.

Frontend:
- api/admin.ts::listAdminTenants — типизированный helper.
- composables/adminTenantsMapper.ts::mapApiAdminTenant — converter
  API → UI: status derive (is_trial→trial, chargeback>0||balance<0
  →overdue), inn='', code=subdomain, tariff clamp на known TenantTariff,
  todayActual/mrrRub отсутствуют в API → 0/null, activitySince через
  formatRelative.
- AdminTenantsView: reactive tenantsState+stats default = MOCK,
  loadTenants() на onMounted → splice replace; на fail — fetchError +
  warning alert + MOCK fallback. Reload-btn.

Vitest +13:
- View-integration (4): listAdminTenants на mount / replace state+stats /
  reject → fetchError + alert + fallback / reload-btn x2.
- Mapper (9): name/code/inn/status-derives (trial/overdue/suspended) /
  balance_rub→number / activitySince + null fallback.

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

Регресс:
- Lint+type-check+format passed.
- Vitest 296/296 за 18.91 сек (+13 от 283).
- Vite build 1.02 сек.
- Pint + PHPStan passed.
- Pest 228/228 за 25.22 сек (+8 от 220, 906 assertions).

Реестр v1.65→v1.66 / CLAUDE.md v1.56→v1.57.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:19:53 +03:00
Дмитрий 7e1bf8b42d phase2(deal-patch): PATCH /api/deals/{id} + comment-editor в DealDetailDrawer (этап 1/5)
Drawer из read-only становится editable. ActivityLog event пишется на
каждое изменение поля.

Backend (DealController::update):
- PATCH /api/deals/{id} {tenant_id, comment?, manager_id?, status?}.
- Каждое изменённое поле → ActivityLog:
  comment → deal.commented (context.text);
  manager_id → deal.assigned (context.from/to + assigned_at=NOW);
  status → deal.status_changed (context.from/to/source='manual').
- NO-OP не пишется в audit. Manager FK guard + status slug validation.
- RLS + defense-in-depth where(tenant_id) → 404 для чужой сделки.

Pest +10 (DealUpdateTest):
- 422/404 базовые / 404 чужая сделка / comment+audit / manager+audit+
  assigned_at / status+audit / 422 неизвестный slug / 422 чужой manager /
  NO-OP не пишет / комбинированно → 2 audit записи.

Frontend:
- api/deals.ts::updateDeal — PATCH helper c ensureCsrfCookie.
- DealDetailDrawer: новая секция «Комментарий» (только при tenantId).
  v-textarea auto-grow + counter=5000 + Save-btn → updateDeal →
  toast success + reload events (новый deal.commented в timeline).
  На fail → warning toast.

Vitest +3 (DealDetailDrawerApi):
- saveComment вызывает updateDeal + toast + reload events (getDeal x2).
- saveComment reject → commentSaveError + warning toast.
- comment-section не рендерится без tenantId.

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

Регресс:
- Lint+type-check+format passed.
- Vitest 283/283 за 18.13 сек (+3 от 280).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 220/220 за 25.64 сек (+10 от 210, 871 assertion).

Реестр v1.64→v1.65 / CLAUDE.md v1.55→v1.56.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:10:58 +03:00
Дмитрий e31ea5354a phase2(lead-statuses): GET /api/lead-statuses + Pinia-store с snapshot fallback
Заменяет static-снапшот LEAD_STATUSES в коде на live-данные из БД.
Custom slug'и (добавленные после deployment'а) теперь видны UI без rebuild'а.

Backend:
- LeadStatus model (PK=slug string, incrementing=false, timestamps=null).
- LeadStatusController::index — GET /api/lead-statuses, ORDER BY sort_order,
  slug. Таблица глобальная (не tenant-aware), auth не требуется на MVP.

Pest +5 (LeadStatusesIndexTest):
- 200 + не пустой / все 14 системных slug'ов из seed / все нужные поля /
  sort_order ASC / кастомный slug после INSERT появляется в endpoint'е.

Frontend:
- api/leadStatuses.ts::listLeadStatuses — GET helper.
- stores/leadStatuses.ts::useLeadStatusesStore — Pinia setup-store:
  statuses default = LEAD_STATUSES snapshot (UI работает без fetch'а),
  load(force=false) идемпотентен, bySlug computed Map, findBySlug helper.
  На fail — snapshot остаётся, fetchError=true.
- DealsView/KanbanView/DealDetailDrawer переехали со static-импорта
  LEAD_STATUSES на store. KanbanView использует safe-access
  dealsByStatus[slug] || [] (защита от custom slug'а из API без seeded
  column). load() в onMounted у обоих view'ов.

Vitest +7 (leadStatusesStore.spec.ts):
- initial snapshot / findBySlug existing & null / load success replace +
  loaded / load reject — fetchError + snapshot fallback / load идемпотентен /
  load(force=true) refetch.
- 2 spec'а DealDetailDrawer получили setActivePinia(createPinia()) в
  beforeEach (без этого Pinia store-injection в jsdom падает).

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

Регресс:
- Lint+type-check+format passed.
- Vitest 280/280 за 19.44 сек (+7 от 273).
- Vite build 1.17 сек.
- Pint + PHPStan passed.
- Pest 210/210 за 24.59 сек (+5 от 205, 840 assertions).

Реестр v1.63→v1.64 / CLAUDE.md v1.54→v1.55.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:59:17 +03:00
Дмитрий cba76c5d18 phase2(deal-show): GET /api/deals/{id} + DealDetailDrawer на реальный ActivityLog
Закрывает gap «timeline в drawer'е показывает hard-coded MOCK_EVENTS» —
теперь drawer fetch'ит реальные activity-events на open из tenant-filtered
activity_log. Без tenant_id — fallback на MOCK_EVENTS как раньше.

Backend (DealController::show):
- GET /api/deals/{id}?tenant_id={id} — возвращает {deal, events}.
- Deal extended (project_name, manager_name/initials, comment, assigned_at).
- Events — последние 50 записей activity_log по (tenant_id, deal_id)
  ORDER BY created_at DESC, с актором (user через belongsTo).
- RLS-обёртка + defense-in-depth where(tenant_id) — 404 если чужая.

Pest +8 (DealShowTest):
- 422/404 базовые / 404 чужая сделка / deal-relations / events ORDER BY +
  actor + actor=null для system-event / RLS+app-фильтр изоляция событий /
  лимит 50 событий.

Frontend:
- api/deals.ts::getDeal — типизированный helper c ApiDealEvent/Detail/Response.
- composables/dealsApiMapper.ts::mapApiDealEvent — converter ApiDealEvent →
  DealEvent: clamp event-slug на known types с fallback на 'deal.viewed';
  detail зависит от type (status_changed: 'from → to'; created: source;
  остальные: JSON-сводка context).
- DealDetailDrawer: optional tenantId prop, watch([open, deal.id, tenantId])
  с immediate=true → loadEvents() на open. Reject → eventsFetchError +
  v-alert warning + MOCK_EVENTS fallback.
- DealsView/KanbanView передают :tenant-id="auth.user?.tenant_id".

Vitest +4 (DealDetailDrawerApi.spec.ts):
- Без tenantId — getDeal не вызывается + MOCK_EVENTS видны.
- С tenantId — getDeal + events замещены + 'new → paid' виден.
- reject → fetchError + alert + MOCK_EVENTS fallback.
- open=false → getDeal не вызывается.

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

Регресс:
- Lint+type-check+format passed.
- Vitest 273/273 за 20.76 сек (+4 от 269).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 205/205 за 24.19 сек (+8 от 197, 812 assertions).

Реестр v1.62→v1.63 / CLAUDE.md v1.53→v1.54.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:21:50 +03:00
Дмитрий a1ea003642 phase2(xlsx-export): PhpSpreadsheet 5.7 + format=csv|xlsx на /api/deals/export
Закрыт TODO «реальный XLSX-export» из v1.51. Russian users prefer .xlsx
(1С/Excel) — заменяет CSV как default. CSV остаётся через format=csv.

Backend (DealController::export):
- Body теперь: {tenant_id, ids, format?: 'csv' | 'xlsx'}; default 'csv'.
- buildXlsx: Spreadsheet + setTitle 'Сделки' + setCellValue A1..G1
  headers + bold(A1:G1) + setAutoSize всех колонок A..G. Writer пишет
  через ob_start/php://output для возврата бинарной строки.
- Content-Type application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
  + Content-Disposition с .xlsx.

Quirk: PhpSpreadsheet 5.x удалил deprecated setCellValueByColumnAndRow —
мигрировал на A1-нотацию (setCellValue('A2', $val)).

Pest +4 (DealCreateTest):
- xlsx binary с Content-Type + magic bytes "PK\x03\x04" + size >2KB.
- IOFactory::createReader('Xlsx') распаковывает: sheet «Сделки» +
  A1='ID' bold + A2/B2/C2 — реальные данные сделки.
- 422 на неизвестный format.
- Default (без format) — backward-compat CSV.

Frontend:
- api/deals.ts разделён: exportDeals (CSV string) + exportDealsXlsx
  (Blob, responseType='blob').
- applyBulkExport(format='xlsx' | 'csv') в DealsView — default 'xlsx'.
  XLSX → triggerBlobDownload (новый helper). CSV → старый CSV-helper.
  На fail — fallback на local CSV.

Vitest +3 (DealsListIntegration):
- xlsx default → exportDealsXlsx + Blob download + toast «XLSX».
- 'csv' → exportDeals + toast «CSV».
- xlsx reject → fallback на local CSV + toast «Backend недоступен».

PHPStan baseline регенерирован (удалена unmatched ignore-запись для
setCellValueByColumnAndRow). cspell-glossary +дефолтит +vnd +spreadsheetml.

Регресс:
- Lint+type-check+format passed.
- Vitest 269/269 за 18.49 сек (+3 от 266).
- Vite build 982 ms.
- Pint + PHPStan passed.
- Pest 197/197 за 26.05 сек (+4 от 193, 784 assertions).

Реестр v1.61→v1.62 / CLAUDE.md v1.52→v1.53.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:08:53 +03:00
Дмитрий ac186593f2 phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.

Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
  partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
  для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.

Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
  batch update 3 сделок + 3 ActivityLog с правильным context /
  NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
  только свой) / 422 пустой массив ids.

Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
  backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
  N из M.», на fail — warning toast + локальный update НЕ откатывается.
  Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
  привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.

Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.

PHPStan baseline регенерирован. cspell-glossary +апдейт*.

Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).

Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:46:19 +03:00
Дмитрий 339e8ea53b phase2(deals-list-api): GET /api/deals + замена MOCK_DEALS на fetch с fallback
Закрыт TODO (c) из v1.50: backend-эндпоинт для списка сделок и его
интеграция в DealsView/KanbanView вместо статичного MOCK_DEALS.

Backend (DealController::index):
- Query-params: tenant_id (required, 422/404), status_in[] (whereIn),
  project_id, manager_id, search (ILIKE по phone+contact_name),
  limit clamp [1..500] default 100, offset default 0.
- ORDER BY received_at DESC, id DESC. Eager-load project + manager.
- RLS-обёртка SET LOCAL app.current_tenant_id + defense-in-depth
  where(tenant_id) — на тестах через postgres superuser RLS обходится
  BYPASSRLS, app-фильтр гарантирует изоляцию.
- Ответ: {deals: [...], total, limit, offset}; manager_name/initials
  форматируются через ManagerController::formatName/formatInitials.

Pest +12 (DealIndexTest):
- 422/404, пустой список, relations (project_name+manager_name+initials),
  RLS-изоляция, ORDER BY, status_in[], project_id, manager_id, search
  ILIKE, limit+offset, manager=null edge case.

Frontend:
- api/deals.ts::listDeals — типизированный helper c ApiDeal/ListDeals*.
- composables/dealsApiMapper.ts::mapApiDeal — converter ApiDeal→MockDeal:
  contact_name fallback на phone, manager.name='Не назначен' /
  initials='—' при null, project='—' при null, cost=0,
  receivedMinutesAgo=max(0, …) от clock-skew.
- DealsView/KanbanView: onMounted(loadDeals) async-вызывает listDeals
  если auth.user.tenant_id, на success replace через splice, на fail
  fetchError=true + v-alert warning, MOCK_DEALS как fallback.

Vitest +14:
- dealsApiMapper.spec.ts (8): 1:1, fallback'и, edge cases.
- DealsListIntegration.spec.ts (6): без tenant_id — НЕ вызывает API,
  с tenant_id — replace state, reject → fetchError + alert + fallback;
  для DealsView и KanbanView.

PHPStan baseline регенерирован. cspell-glossary +ILIKE +DTO.

Регресс:
- Lint+type-check+format passed.
- Vitest 261/261 за 19.62 сек (+14 от 247).
- Vite build 989 ms.
- Pint + PHPStan passed.
- Pest 186/186 за 22 сек (+12 от 174, 742 assertions).

Реестр v1.59→v1.60 / CLAUDE.md v1.50→v1.51.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:34:39 +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
Дмитрий 515114cff5 phase2(lookups+integrity): GET /api/managers+projects + manager FK guard + SupplierLeadCost для manual
3 интеграционных доработки после backend-completion v1.57.

(1) GET /api/managers + /api/projects + manager FK guard:
- ManagerController::index — active users тенанта (is_active+deleted_at IS NULL).
  Формат {id, email, first_name, last_name, name, initials} с
  formatName/formatInitials helpers (fallback на email).
- ProjectController::index — active projects (is_active=true).
- Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка.
- DealController::store FK guard: manager_id должен принадлежать tenant'у +
  is_active. Иначе 422 (закрывает security-gap чужого менеджера).
- Pest +8 в LookupsTest.

(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog:
- projectOptions/managerOptions ref'ы с MOCK fallback.
- loadLookups через Promise.all([listProjects, listManagers]) на open
  диалога с tenantId.
- managerIdByName Map name→id для submit'а.
- Silent fallback на mock при network-error.
- Vitest +2.

(3) SupplierLeadCost для manual-leads:
- В DealController::store после Deal::create — resolveSupplierId (копия
  логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY
  sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub
  + supplier_lead_id=NULL (manual: нет внешнего id).
- Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge
  только при webhook'е); cost-аналитика всё равно нужна.
- Pest +2.
- TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы
  Job + Controller разделяли логику.

Старый тест manager_id=42 переписан под FK guard через User::factory.

PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings).

Регресс: lint+type-check+format ; vitest 247/247 за 16.32 сек (+2);
vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек
(+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49.

Production TODO остаточные:
- resolveSupplierId → SupplierResolver service.
- XLSX-export через PhpSpreadsheet.
- GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:58:49 +03:00
Дмитрий 83bb9de2bb phase2(backend-completion): POST /api/deals + webhook_hmac_required + POST /api/deals/export
3 backend-completion после tightening v1.56.

(1) POST /api/deals — manual create endpoint:
- DealController::store. Project firstOrCreate (type='manual'). Deal с
  source_crm_id=NULL. RLS-обёрнутая транзакция.
- Manual НЕ списывает баланс / НЕ дедуп / НЕ SupplierLeadCost.
  ActivityLog с context.source=manual.
- NewDealDialog получил optional tenantId prop. С tenantId — POST → backend-id;
  на error fallback на local-id + warning + dialog open.
- DealsView/KanbanView передают auth.user?.tenant_id.
- Pest +8.

(2) webhook_hmac_required flag в system_settings:
- Seed-row в db/schema.sql (default false backward-compat).
- WebhookReceiveController::isHmacRequired private helper.
- При true: запрос без X-Webhook-Signature → 401.
- Pest +3.

(3) POST /api/deals/export — backend CSV:
- DealController::export. Валидация ids[1-10000]. RLS-обёрнутый whereIn.
- Excel-friendly CSV: BOM "\u{FEFF}" PHP-литерал, ; разделитель, \r\n.
- text/csv attachment headers.
- Frontend applyBulkExport: backend → fallback на client-side
  (buildLocalCsv вынесен).
- Pest +4.

Vitest +3 (всего 245/245).
PHPStan убрал лишнюю Deal->id===null проверку (Eloquent int).
DealsView/KanbanView spec'ы получили setActivePinia.

Регресс: lint+type-check+format ; vitest 245/245 за 17.07 сек (+3);
vite build 1.04 сек; Pint+PHPStan passed; Pest 156/156 за 20.27 сек
(+15 от 141, 675 assertions). Реестр v1.56→v1.57, CLAUDE.md v1.47→v1.48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:43:21 +03:00
Дмитрий 4a385b1df7 phase2(prod-tightening): HMAC+rate-limit webhook / fetch system_settings / CSV export
3 production-tightening после 7-фичного пакета v1.55.

(1) HMAC + per-token rate-limit для webhook receive endpoint:
- WebhookReceiveController::receive: tenant lookup → rate-limit → HMAC
  → payload validation.
- HMAC: опциональный X-Webhook-Signature: sha256=<hex> через hash_hmac +
  hash_equals (constant-time). Backward-compat: header missing → 202.
- Per-token rate-limit: RateLimiter с decay 60 сек. Лимит из
  system_settings.webhook_rate_limit_rps × 60. На превышении 429 +
  Retry-After. Hit ставится ДО валидации payload — иначе обходимо 422.
- Pest +5: HMAC valid/invalid 401/missing 202; rate-limit 60+1=429;
  ключ изолирован per-token.

(2) Реальный fetch system_settings в AdminSystemView:
- onMounted → adminApi.listSystemSettings() → splice replace.
- На fetch-error → fallback на mock + warning v-alert.
- Кнопка «Обновить» — ручной reload.
- Vitest +3: mount fetch / reload / error fallback.

(3) Реальный CSV-export для bulk-actions DealsView:
- applyBulkExport → CSV через Blob+a[download].
- 8 колонок, ; разделитель, \r\n, BOM через String.fromCharCode(0xFEFF)
  (литеральный U+FEFF блокируется ESLint no-irregular-whitespace).
- Filename deals_export_YYYY-MM-DD.csv.
- Empty selection → toast без download.
- Vitest +2: spy createObjectURL+anchor.click; empty без blob.

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

Регресс: lint+type-check+format ; vitest 242/242 за 15.82 сек (+4);
vite build 903 ms; Pint+PHPStan passed; Pest 141/141 за 17.8 сек (+5,
627 assertions). Реестр v1.55→v1.56, CLAUDE.md v1.46→v1.47.

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