Commit Graph

64 Commits

Author SHA1 Message Date
Дмитрий c64be74992 fix(import): final review — /import в явный список Route::view
Final review (🟢 low): SPA-маршрут /import работал через Route::fallback,
но все остальные app-маршруты перечислены явно в Route::view-блоке
(CLAUDE.md документирует явный список как намеренный паттерн — catch-all
перехватывал бы _test/* runtime-роуты Pest). /import добавлен в список
для консистентности и устойчивости.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:33:15 +03:00
Дмитрий a7038367e4 feat(import): ImportController + маршруты /api/imports
Task 9 Sprint 4: ImportController с 5 методами (store/index/show/
unknownStatuses/resolveUnknownStatuses), 2 FormRequest (StoreImportRequest
/ ResolveUnknownStatusesRequest), 5 маршрутов в routes/web.php под
auth:sanctum+tenant. Defense-in-depth: явный where(tenant_id) поверх RLS
(postgres superuser обходит BYPASSRLS на dev — паттерн DealController).
Тест 8/8, Larastan baseline regen (только TestCall false positives).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:45:47 +03:00
Дмитрий 447ef593fa feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:18:13 +03:00
Дмитрий 9f70d89046 feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/* 2026-05-16 15:01:07 +03:00
Дмитрий 3269434746 feat(admin): G6 backend — incident РКН-notify endpoint 2026-05-16 14:09:53 +03:00
Дмитрий 5e12126d71 feat(admin): G5 backend — incident detail endpoint 2026-05-16 14:09:53 +03:00
Дмитрий adabcf15a4 feat(admin): G4 backend — billing tenant actions (status/refund/tariff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:09:53 +03:00
Дмитрий 086fc1a903 feat(reports): download endpoint + signed URL 24ч (F2 backend) 2026-05-16 12:36:08 +03:00
Дмитрий e64eb4dbe0 feat(dashboard): GET /api/dashboard/summary — агрегат KPI/баланса/активности (audit J3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:08 +03:00
Дмитрий 040d25423d feat(billing): wallet/transactions/invoices read API (E3)
GET /api/billing/wallet (баланс + тариф + runway), /transactions
(пагинированный balance_transactions с фильтром type), /invoices
(saas_invoices, real-but-empty до Б-1). TariffPlan модель +
Tenant::tariff() relation + BalanceTransactionFactory.

Sprint 2 Plan C, audit E3 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:08:09 +03:00
Дмитрий 44dc1025ec feat(billing): topup ledger service + POST /api/billing/topup stub (E1)
BillingTopupService кредитует tenants.balance_rub (bcmath) и пишет
append-only строку balance_transactions(type='topup'). BillingController
+ route POST /api/billing/topup под [auth:sanctum, tenant]. MVP-stub:
без платёжного шлюза (ЮKassa — post-Б-1).

Sprint 2 Plan C, audit E1 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:46:52 +03:00
Дмитрий 3266909346 feat(api): outbound webhook settings endpoints (closes J5 part 2)
Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in
schema but had zero code. Adds the OutboundWebhookSubscription model +
factory and WebhookSettingsController with GET/PUT
/api/tenants/me/webhook-settings (one subscription per tenant; secret
generated + returned once on creation, bcrypt-hashed) and POST
/api/webhooks/test (unsigned connectivity check — HMAC-signed event
delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum +
tenant middleware.

phpstan-baseline.neon: additive-only entries for new test file
(Pest\PendingCalls\TestCall false-positives — documented project pattern)
and OutboundWebhookSubscriptionFactory method.childReturnType (same
pattern as ProjectFactory/TenantFactory/UserFactory already in baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:13:32 +03:00
Дмитрий a5e2bbbbe8 feat(api): api_keys model + GET/regenerate endpoints (closes J5 part 1)
Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys). phpstan-baseline.neon updated for
Pest PendingCalls false-positives in the new test file; also removes
8 pre-existing stale ignore.unmatched entries (properties now resolved
by existing @mixin IdeHelper* docblocks — confirmed pre-existing via
git stash test before Task 3 changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:53:35 +03:00
Дмитрий d8d2f37598 feat(auth): PATCH /api/auth/me profile update endpoint (closes J6)
Audit J6: ProfileTab needs a full-profile update endpoint. Adds
AuthController::updateProfile (first_name/last_name/phone/timezone),
routed in the existing /api/auth auth:sanctum group; mirrors the
sibling updateNotificationPreferences. userResource() now also returns
phone + timezone so the GET /me round-trip carries them.

phpstan-baseline.neon updated for Pest PendingCalls false positives
in the new test file (same pattern as all other Feature test files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:58:00 +03:00
Дмитрий 012053a783 feat(auth): /legal/offer + /legal/privacy stub pages (closes A7)
Audit A7: the «Оферта» / «Политика» links in the AuthLayout footer were
raw <a href> pointing at unrouted paths -> 404 via the SPA catch-all.
Adds a single DRY LegalDocView served by /legal/:doc(offer|privacy),
rendering an honest «document being finalized» stub (real legal text
needs юр. редактура — реестр K3 / blocker Б-1). Footer links upgraded
to <RouterLink> for SPA navigation. Also refreshes two stale auth-layout
doc-comments left by the /recovery removal (review M1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:37:20 +03:00
Дмитрий 70508b6675 refactor(auth): remove orphaned /recovery RecoveryCodesView page (closes A2, A3)
Audit A2/A3: RecoveryCodesView (route /recovery) had a TODO no-op
continue handler and 8 hardcoded mock codes. Recon found the page is
orphaned — nothing in the UI navigates to /recovery. The real 2FA
recovery-codes flow lives entirely in Settings -> Безопасность
(TwoFactorCard setup wizard + RecoveryCodesCard regeneration), both
already wired to the real API. Per user decision (2026-05-15) the
orphan is deleted rather than polished.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:26 +03:00
Дмитрий b9038bc3eb chore(routes): add explicit Route::view for /projects, /reminders, /admin/*
Phase 6 audit found inconsistency in routes/web.php SPA-shell list.
Comment (line 188-190) declares «Регистрируем явно, а не catch-all»
for test isolation, but the explicit list missed:
- /reminders, /projects (main views from Plan 5)
- /admin and 7× /admin/* (added in Plans 4 + 5)

These paths worked via Route::fallback (line 211), but that risks
runtime-routes from Pest beforeEach('_test/*') being shadowed by
fallback BEFORE catch-all. Align explicit list with router/index.ts
to honor the documented rationale.

No behavioral change for production (same welcome view returned);
test-suite isolation contract restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:25:19 +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
Дмитрий 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
Дмитрий 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
Дмитрий 280cfcd6cf feat(routes): register POST /api/webhook/supplier/{secret}
Spec §5.1 supplier-webhook endpoint. SupplierWebhookController tests
переходят с 405 на 8/8 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:39:43 +03:00
Дмитрий cd13e6c8bb refactor(backend): Sprint 3 Phase B — AuthController split (3 classes by analogy)
Sprint 3 Phase B. Закрытие audit O-refactor-02:

- O-refactor-02: AuthController (595 строк) → split на 3 класса (по аналогии с
  Sprint 3 Phase A DealController split):
  * AuthController (343) — core auth: login/register/logout/me +
    updateNotificationPreferences + helpers (loginThrottleKey, isIpLockedOut,
    maybeNotifySuspiciousLogin, logAuthEvent, lockoutResponse, userResource)
  * TwoFactorController (217, новый) — 2FA verify + recovery codes use
    (login-flow продолжение). Setup/enable/disable остались в
    TwoFactorSetupController (с Sprint 1).
  * PasswordResetController (146, новый) — forgot/reset password

API endpoints без изменений (только routing — controller@method обновлён в web.php):
- POST /api/auth/2fa/verify         → TwoFactorController@verifyTwoFactor
- POST /api/auth/2fa/recovery-use   → TwoFactorController@useRecoveryCode
- POST /api/auth/forgot             → PasswordResetController@forgotPassword
- POST /api/auth/reset-password     → PasswordResetController@resetPassword

Helpers lockoutResponse и userResource дублируются между классами (1:1) — по
принципу Phase A: «копируй методы 1:1, не оптимизировать на ходу». Будущая
итерация может вынести в trait/base controller, если появится 4-й класс.

Pest: 416/416 PASS + 2 skipped.
Larastan: 0 errors.
Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:14:33 +03:00
Дмитрий 86dc930915 refactor(backend): Sprint 3 Phase A — DealController split + N+1 fix + export streaming
Sprint 3 Phase A. Закрытие audit O-refactor-01 + O-perf-01 + O-perf-05.

O-refactor-01: DealController (802 строки) → split на 3 контроллера
по ответственности:
  * DealController (466 строк) — single-resource CRUD (index/show/store/update).
  * DealBulkActionController (264 строки, новый) — bulk transition/destroy/restore.
  * DealExportController (120 строк, новый) — export() с streaming.
API endpoints без изменений; в routes/web.php обновлены только controller@method.

O-perf-01: N+1 в bulk-actions устранён в новом DealBulkActionController.
  * transition: SELECT (id, status) → filter NO-OP → bulk-UPDATE
    whereIn(changed)->update(status) + ActivityLog::insert(массив).
    100 сделок: ~200 SQL → 2 SQL (после SELECT 1 UPDATE + 1 INSERT).
  * destroy/restore: SELECT id'шников живых/удалённых → bulk-UPDATE
    deleted_at + ActivityLog::insert. Аналогично 2 SQL вместо N.
  Defense-in-depth where(tenant_id) сохранён — DealTransitionTest
  «не апдейтит чужие сделки» проходит.

O-perf-05: export() переписан на OpenSpout streaming (composer require
openspout/openspout ^5.3). PhpSpreadsheet строил весь .xlsx в памяти
(10K сделок ≈ 100+ MB RAM). Теперь:
  * Writer::openToFile('php://output') + Row::fromValues + chunkById(500).
  * StreamedResponse → пик памяти O(1) от размера экспорта.
  * CSV: OpenSpout Options(FIELD_DELIMITER=';', SHOULD_ADD_BOM=true) —
    Excel-friendly RU-локаль сохранена.
  * XLSX: getCurrentSheet()->setName('Сделки'), header через
    Row::fromValuesWithStyle с (new Style)->withFontBold(true).

DealCreateTest.php (4 теста про export): getContent() → streamedContent()
для StreamedResponse + getCell()->getFormattedValue() для inline-string
ячеек, которые OpenSpout пишет как RichText (PhpSpreadsheet writer писал
как plain shared-string). Логика тестов и assertion'ы не меняются.

Verification:
  Pest: 416/416 passed (+ 2 skipped), 1388 assertions, 47.5s.
  Larastan: 0 errors above baseline.
  Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:08:20 +03:00
Дмитрий 20d4682e21 feat(backend): Sprint 2 Phase A — Pest 4 browser scaffold + mutation + lazy-loading + Larastan cache
Sprint 2 Phase A (modernization). Закрытие audit O-stack-01/02/03 + O-perf-07:

- O-stack-01: Pest 4 browser-tests scaffold. testsuite Browser в phpunit.xml,
  Pest.php extend(...)->in('Browser'), tests/Browser/SmokeTest.php
  (login-flow + deal-create-flow). На Windows native PHP плагин
  pest-plugin-browser требует ext-sockets (отсутствует в стандартной
  сборке) и при автозагрузке вызывает socket_create_listen() ДО старта
  тестов, что ломает весь Pest. Поэтому плагин НЕ в require-dev,
  тесты помечены ->skip(...) с инструкцией активации на Linux CI.
  Скелет тестов в комментариях — на CI достаточно
  composer require pestphp/pest-plugin-browser --dev + npx playwright
  install + раскомментировать тела.
- O-stack-02: infection/infection ^0.32.7 в require-dev + app/infection.json
  (minMsi=50, source: Http/Models/Services). composer mutation script.
  Запуск отдельной задачей в CI (медленный). allow-plugins для
  infection/extension-installer.
- O-stack-03: Laravel 13 string-based lazy-loading в routes/web.php —
  убран блок use App\Http\Controllers\Api\* (16 импортов), все
  ссылки заменены на строки 'App\Http\...\X@method'. Контроллеры
  не загружаются автозагрузчиком при boot route'ов; класс резолвится
  только при матче маршрута. Использован строковый синтаксис, а не
  FQN-class — Pint default preset (fully_qualified_strict_types fixer)
  сворачивает FQN-class обратно в use, на строки не действует.
- O-perf-07: Larastan result cache через tmpDir: .phpstan-cache в
  phpstan.neon (cache-dir не дублируется флагом — phpstan не принимает
  оба источника). lefthook job 6 (larastan) использует этот же
  tmpDir автоматически. Ускорение инкрементальных pre-commit прогонов:
  на правке одного файла — переанализ только зависящих, не всего
  графа классов. .phpstan-cache в .gitignore.

Pest: 416/416 PASS + 2 skipped (browser smoke pending Linux CI).
Larastan: 0 errors above baseline.
Infection: vendor/bin/infection --version → 0.32.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:18:38 +03:00
Дмитрий 56195c8a59 fix(backend): tenant middleware на auth-routes + HasPasswordRules trait + test password (audit P0-01 + O-refactor-03 + P2-01)
Закрытие аудита 2026-05-09 (b6ae8dd):
- P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам:
  /api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63).
  /api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F.
- O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register.
- P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest (≥8 chars).
- bonus-fix: SetTenantContext::resolveTenantId — property_exists() заменён на isset() для
  Eloquent magic-attributes (auth-путь резолюции tenant_id никогда не работал из-за этого
  бага; тесты-смоки middleware покрывали только X-Tenant-Id header / subdomain). Без фикса
  P0-01 ломает 58 тестов в /api/notifications + /api/reminders + /api/reports/jobs.

Pest: 416/416 PASS.
Larastan: 0 errors.
Pint: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:22:30 +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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 768628d914 phase2(7-features): bulk-actions / new-deal / tenant-card / system-edit / webhook / smart-filters / impersonation-list
7-фичный auto-mode пакет согласно «карте что осталось» (после v1.54).

(1) Bulk-actions DealsView:
- dealsState reactive-копия MOCK_DEALS (deep-clone) для безопасного bulk-edit.
- Bulk-bar (sticky, теало-нуар, theme=dark) при selected.length > 0:
  count + Сменить статус (v-menu × 14 lead_statuses) + Экспорт (snackbar) +
  Удалить (v-dialog confirm) + ✕ clear.
- На production: smart status-transition с проверкой allowed-переходов;
  soft-delete (архив 30 дней); реальный CSV/XLSX export через xlsx-lib.

(2) NewDealDialog (used in DealsView+KanbanView):
- 6 полей: name/phone/project (MOCK_PROJECTS) / manager (MOCK_MANAGERS) /
  cost / status (default 'new' или presetStatus). Phone-валидация ≥10 цифр.
- emit('created', deal) → DealsView push в начало dealsState; KanbanView push
  в правильную колонку по statusSlug + totalDeals++.

(3) AdminTenantDetailView (/admin/tenants/:code):
- 4 KPI cards (Баланс/runway / Тариф+MRR/мес / Лиды сегодня+неделя+месяц /
  Средняя цена). 4 v-tabs: Финансы (balance-history) / Пользователи /
  Проекты / Активность с event-кодами.
- Кнопка «Войти как клиент» (использует ImpersonationDialog из v1.54).
  404-fallback. composables/mockTenantDetail.ts с expandTenantDetail.
- AdminTenantsView получил @click:row → router.push.

(4) Edit-flow AdminSystemView (audit-log + 2-step):
- Backend: SystemSetting + SaasAdminAuditLog Eloquent (append-only,
  payload_before/after JSONB casts).
- AdminSystemSettingsController с GET (list) + PUT (update в DB::transaction
  + INSERT в saas_admin_audit_log; hash-chain trigger BEFORE INSERT
  заполняет log_hash).
- Type-validation: int/decimal/bool/json. Reason ≥30 chars. No-op → 422.
- Frontend SystemSettingEditDialog — 3-step (edit → confirm с diff
  before/after → done).

(5) Webhook receive endpoint (POST /api/webhook/{token}):
- WebhookReceiveController::receive. Token = tenants.webhook_token.
- 404 unknown / 422 bad payload / 202 success + dispatch ProcessWebhookJob.
- Stub-INSERT в webhook_log через DB::table обёрнут в DB::transaction +
  SET LOCAL app.current_tenant_id для RLS.
- CSRF-исключение для api/webhook/* в bootstrap/app.php.
- На prod: + HMAC X-Webhook-Signature + per-token rate-limit.

(6) Smart-filters:
- DealsView: multi-select v-select Проект+Менеджер с auto availableProjects/
  availableManagers computed.
- AdminTenantsView: filterStatuses (4 STATUS_OPTIONS) + filterTariffs
  (computed availableTariffs).
- Кнопка «Сбросить» появляется только когда фильтры активны.

(7) AdminImpersonationView (/admin/impersonation):
- Backend +2 GET endpoints: /active (used_at != null AND session_ended_at
  == null) + /recent (last 20 завершённых с duration_seconds через
  abs(diffInSeconds) — Carbon signed по умолчанию).
- ImpersonationToken получил belongsTo(Tenant).
- Frontend view: 2 секции (Активные с end-кнопкой / Недавно завершённые
  read-only) + refresh + onMounted load.
- Маршрут /admin/impersonation + 5-й nav-пункт «Impersonation» в AdminLayout.

Vitest +48 (всего 238/238 за 15.31 сек).
Pest +16 (всего 136/136 за 15.8 сек, 495 assertions).
PHPStan baseline регенерирован (0 errors после фикса nullsafe.neverNull).

Регресс: lint+type-check+format ; vite build 937 ms; Pint+PHPStan passed;
Pest 136/136. Реестр v1.54→v1.55, CLAUDE.md v1.45→v1.46.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:33:21 +03:00
Дмитрий 1963694778 phase2(impersonation): backend Ю-1 (init-token + verify + end)
- ImpersonationToken Eloquent (UPDATED_AT=null) + isExpired/isUsable helpers
- ImpersonationController 3 endpoints: init / verify / end
- init: reason ≥30 chars, 6-значный random_int код, bcrypt-hash, TTL 15 мин
- verify: Hash::check; failed_attempts+=1; ≥5 → invalidated_at
- end: session_ended_at; 422 если не было verify
- _dev_plain_code в response (prod заменит email клиенту)
- Routes /api/admin/impersonation/{init,verify,end} (без middleware на MVP)
- TODO: saas-admin auth (Yandex 360 SSO) + two-person approval (CTO-15/Ю-9) + email клиенту + UI dialog в AdminTenantsView
- Pest +9 ImpersonationTest (120/120 за 15.62с, 443 assertions)
- Регресс: lint+type+format OK; build 846ms; Pint+Stan passed
- CLAUDE.md v1.43→v1.44, реестр v1.52→v1.53
- Все 9 пунктов списка v1.46 закрыты (кроме #6 SSO ⏸ Б-1 и #7 browser-mode отложен инфра)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:24:02 +03:00
Дмитрий 73e64128dc phase2(2fa-setup): wizard init+confirm+disable+regenerate в SettingsView/SecurityTab
- TwoFactorSetupController (auth:sanctum): /api/2fa/{init,confirm,disable,regenerate-recovery-codes}
- init секрет в session (не в БД), QR-URL otpauth://; confirm активирует 2FA + 8 recovery codes
- disable/regenerate требуют password-confirmation
- User.casts: totp_secret => encrypted

Schema v8.7→v8.8: users.totp_secret VARCHAR(255) → TEXT (encrypted ~256 chars)
Migration fix: explicit ALTER TABLE webhook_dedup_keys ADD FK после DB::unprepared (PDO глотал FK на partitioned)
PartitionsCreateMonthsTest fix: DETACH PARTITION + DROP вместо DROP CASCADE

Frontend: SecurityTab реальная логика (setup wizard 3 шага, disable, regenerate dialogs)

- Pest +10 (101/101 за 13.37с, 364 assertions)
- Vitest 166/166
- CLAUDE.md v1.39→v1.40, реестр v1.48→v1.49, schema v8.7→v8.8

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