c94de99c3276fa5625533aedebc031ad16114a3e
943 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
62aa55f033 |
feat(models): add SupplierProject Eloquent model + factory + tests
- app/Models/SupplierProject.php — fillable + casts (jsonb arrays + datetime) + scopes: active() (inactive_since IS NULL), staleSince(N days), forSignal(signal_type, unique_key) - database/factories/SupplierProjectFactory.php — корректно учитывает chk_supplier_projects_b1_not_for_sms (B1 не порождает SMS-проекты) - tests/Feature/Models/SupplierProjectTest.php — 6 тестов: factory, array casts (workdays + regions), scopeActive, scopeStaleSince, scopeForSignal (3 платформы на один домен — UNIQUE (platform,unique_key)) ide-helper:models -W -M -N перегенерил docblocks для 4 существующих моделей (SaasAdminAuditLog, SystemSetting, UserRecoveryCode, ImpersonationToken) — синхронизировал @property после schema v8.16. Pest: 451 / 449 passed / 2 skipped (было 443+6 новых от Task 6 = 449). Larastan: 0 errors. Pint: passed. Spec: §2.2 Plan: Task 6 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
757f963929 |
fix(db): consolidate Plan 1 Tasks 1-5 into schema.sql (project convention)
Project convention использует schema.sql как single source of truth + один
load_initial_schema migration вместо incremental migrations. Мои 5 incremental
миграций конфликтовали с migrate:fresh: load_initial_schema применял
обновлённый schema.sql v8.16, а потом 000001-000005 пытались добавить уже
существующие колонки/таблицы (`signal_type already exists`).
Изменения:
- Удалены 5 incremental миграций 2026_05_10_00000{1..5}_*.
Все DDL уже в schema.sql (v8.11 → v8.16, 4 commits 2ebe000/9b99d81/
b08e1ed/7f694f7/9cf380f).
- В schema.sql lead_charges FK на partitioned deals(id, received_at)
вынесен в самый конец файла (после section 5 с deals), DEFERRABLE
INITIALLY DEFERRED. Иначе DB::unprepared() выдаёт "deals не существует"
на load.
- Тесты в tests/Feature/Integration/ остаются — они проверяют
структурные свойства (column existence, constraint name, RLS via
pg_class) через information_schema, не зависят от того как именно
schema создалась.
Verification:
- migrate:fresh OK на обеих БД (liderra + liderra_testing)
- Pest: 445 tests / 443 passed / 2 skipped / 0 failed
(было 421 baseline + 24 новых для Tasks 1-5 = 443; +2 skipped browser tests)
- Larastan: 0 errors
- Pint: passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9cf380f170 |
feat(db): create supplier_sync_log audit table (SaaS-level, append-only)
Append-only journal AJAX-синхронизаций с поставщиком crm.bp-gr.ru. Используется для retry, отладки rt-project-* и алертов менеджеру. - 9 columns: id, supplier_project_id (nullable FK SET NULL), action, request_payload (jsonb), response_body (jsonb), http_status, error_message, duration_ms, created_at - 1 CHECK chk_supplier_sync_log_action (create/update/delete/disable/session_refresh) - 3 индекса: supplier_project_id, action, created_at - REVOKE ALL FROM crm_app_user (DO $$ conditional) - No RLS (SaaS-level) Spec: §4.3 Plan: Task 5 Test: 4/4 passed (table, action enum, FK, no RLS). Schema v8.15 → v8.16. Метрики: 60 таблиц (+1) / 108 индексов (+3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7f694f78e0 |
feat(db): create lead_charges ledger (tenant-scoped RLS, FK to partitioned deals)
Append-only ledger списаний за каждый доставленный лид. Tenant-scoped
с RLS tenant_isolation (ENABLE + FORCE + USING/WITH CHECK).
- 8 columns: id, tenant_id, deal_id, deal_received_at, tier_no,
price_per_lead_kopecks, charged_at, created_at
- Composite FK lead_charges_deals_fk(deal_id, deal_received_at) →
deals(id, received_at) DEFERRABLE INITIALLY DEFERRED
(deals партиционирована — DEFERRABLE для атомарного deal+charge)
- 2 индекса: (tenant_id, charged_at), (deal_id, deal_received_at)
- RLS на (tenant_id = current_setting('app.current_tenant_id')::bigint)
- GRANT SELECT, INSERT для crm_app_user (без UPDATE/DELETE — append-only)
Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4
Plan: docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md Task 4
Test: 3/3 passed (table exists, composite FK to deals, RLS enforces
tenant isolation via testing_rls_user role).
Schema v8.14 → v8.15. Метрики: 59 таблиц (+1) / 105 индексов (+2) /
39 RLS (+1) / функции/триггеры без изменений.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b08e1edb33 | feat(db): create pricing_tiers table (7-step volume billing, kopecks integer) | ||
|
|
9b99d81deb |
feat(db): create supplier_projects table (SaaS-level aggregate, B1/B2/B3 platforms)
Plan 1/5 Task 2 — SaaS-level агрегатная сущность для проектов у поставщиков. Несколько Лидерра-tenant'ов могут шарить один supplier_project (sharing-model spec §2.3): для site/call — по domain/phone; для sms — по (sender, keyword) на B2 или (sender) на B3. RLS НЕ применяется (таблица не tenant-scoped), defense-in-depth через REVOKE ALL FROM crm_app_user. Колонки: platform, signal_type, unique_key (TEXT), supplier_external_id, current_limit, current_workdays/regions (jsonb), sync_status (pending/ok/failed), last_synced_at, inactive_since (TTL 180 дней), timestamps. CHECK constraints (chk_supplier_projects_*): - platform IN (B1, B2, B3) - signal_type IN (site, call, sms) - sync_status IN (pending, ok, failed) - NOT (platform=B1 AND signal_type=sms) — B1 не поддерживает СМС Indexes: UNIQUE(platform, unique_key); btree на sync_status, inactive_since. Тесты: 6/6 (table+columns / unique / platform CHECK / sync_status CHECK / no RLS / no privileges). Schema: v8.12 → v8.13. Метрики: 56→57 таблиц / 98→101 индексов; RLS/функции/триггеры без изменений. |
||
|
|
2ebe000271 |
feat(db): extend projects for supplier integration (signal_type, identifier, sms_senders, sms_keyword, delivered_in_month, b1/b2/b3 FK placeholders)
Plan 1/5 Task 1. Adds 8 columns + 3 CHECK constraints + 1 composite index to projects table for supplier integration foundation. Schema bumped v8.11 to v8.12. Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1. 7/7 Pest tests pass; rollback verified. |
||
|
|
b912724cf7 |
chore(frontend): Sprint 4 Phase C — bundle analyzer + dead-code cleanup (audit O-refactor-06)
- rollup-plugin-visualizer + script `npm run build:analyze` (env BUILD_ANALYZE=1)
Output: storage/bundle-analyze.html (gzip + brotli sizes), gitignored.
- cross-env установлен для Windows-совместимости env-переменной build:analyze.
- knip + knip.config.ts (entry app.ts + router/index.ts; ignore *.story.vue + tests/).
ВАЖНО: knip падает с oxc-parser ArrayBuffer fail на этой машине
(Windows quirk feedback memory) — конфиг сохранён для будущих запусков на
Linux/macOS CI. Dead-code search выполнен вручную через grep по composables/.
- Удалены 4 unused exports + 4 private helpers, инициируемых только ими:
* mockReports.ts: MOCK_JOBS, QuotaInfo (interface), MOCK_QUOTA
* reportsMapper.ts: reportTypes()
* mockTenantDetail.ts: expandTenantDetail() + 4 SAMPLE_* consts
(SAMPLE_USERS/SAMPLE_PROJECTS/SAMPLE_BALANCE_HISTORY/SAMPLE_ACTIVITY)
* useCsvDownload.ts: csvEscape() — снят `export` (используется внутри файла)
ESLint + vue-tsc + Vitest 416/416 + build — зелёные.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
79ff60ffd9 |
refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост)
ErrorView 320→178 (+ ErrorBrand 54 + ErrorIllustration 31 + ErrorActions 55 + ErrorMeta 102).
DashboardView 302→84 (+ DashboardPageHead 65 + DashboardKpiRow 97 + DashboardBalance 124).
State (config, errorCode в ErrorView; range/kpis/balance в DashboardView) остаётся
в parent ради единого route.meta-driven flow + future API-fetch'а (Phase B/1 паттерн).
DashboardPageHead использует Vue 3.5 defineModel<T>() для двусторонней привязки range.
Sub-components читают только props — без Pinia stores (mock-data flow).
Все sub-components <250 строк (acceptance threshold). Shell line counts: 178/84.
ЗАМЕЧАНИЕ по acceptance «0 components >300»: НЕ закрыто полностью. 2 файла остались
выше порога — DealsView 560 + DealDetailDrawer 386. Зафиксировано в Sprint 3 Phase C
commit
|
||
|
|
849bc73290 |
refactor(frontend): Sprint 4 Phase B/2 — split 3 user views (audit O-refactor-04 хвост)
BillingView 416→114 (+ BalanceCard 155 + TransactionsTable 113 + InvoicesTable 90 + billingFormatters 51 composable: formatPlain/formatCost/statusChipColor/ statusLabel/formatLabel/formatIcon/txAmountClass). SecurityTab 354→39 (+ ChangePasswordCard 17 + TwoFactorCard 218 + RecoveryCodesCard 104 + SessionsTable 66; auth-store читается напрямую в каждом sub-component). RemindersView 345→183 (+ RemindersFilters 51 + RemindersList 173; ReminderDialog уже отдельный с прошлой фазы — служит как ReminderForm). State (`activeTab`, `editingReminder`, `deletingReminderId` в RemindersView) остаётся в parent ради единого reload-flow + confirm-dialog'ов. Auth-store читается напрямую в TwoFactorCard/RecoveryCodesCard через useAuthStore() — без prop-drilling. Reminders-store читается напрямую в RemindersFilters/ RemindersList. Все sub-components <250 строк (acceptance threshold). 3 view-shells: 114/39/183. Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 968 ms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
30ef61dff8 |
refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост)
3 view'а с >300 строк разделены на shell + sub-components: AdminTenantsView 377→155 (+ TenantsStatsHeader 82 / TenantsFilters 93 / TenantsTable 116). AdminTenantDetailView 436→109 (+ TenantDetailHeader 158 / TenantDetailTabs 176 + adminTenantDetailFormatters 43 composable). AppLayout 466→78 (+ AppSidebar 155 / AppTopbar 269; R0.6 hard-стоп снят явным запросом заказчика 10.05.2026). State (filterStatuses, tenantsState, activeTab, tenant, drawerOpen) остаётся в parent view'ах ради `defineExpose`-контракта Vitest тестов. Sub-components читают Pinia stores напрямую (auth + notifications + reminders) — без prop-drilling. AppTopbar 269 строк <300 — acceptance threshold выдержан (можно дальше split на NotificationsDropdown + UserMenu в отдельном flow, не критично). Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 1.17 сек. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
6c2f0ce682 |
refactor(frontend): Sprint 3 Phase C — Top-3 Vue components split (audit O-refactor-04)
Sprint 3 Phase C. Закрытие audit O-refactor-04 (частичное — Top-3 из 12): - DealsView.vue: 852 → 560 строк. Выделены: DealsFilters (123), DealsBulkBar (150), DealsTable (165). CSV-utilities (csvEscape/triggerCsvDownload/triggerBlobDownload/ buildCsvString) вынесены в composables/useCsvDownload.ts. - ReportsView.vue: 592 → 261 строк. Выделены: ReportRequestForm (тип отчёта, даты, фильтры, формат, submit/reset), ReportJobsList (список заданий со статусами и actions retry/cancel/delete). - DealDetailDrawer.vue: 580 → 386 строк. Выделены: DealDetailHero (header + phone-link + status-chip), DealDetailTimeline (activity log с MOCK_EVENTS). Comment- и Reminders-секции оставлены inline — связаны с API и defineExpose. DealsView и DealDetailDrawer остались выше 350-целевого уровня: bulk-action функции (applyBulkStatus/applyBulkDelete/applyBulkExport/undoBulkDelete/ applyBulkRestoreFromTrash) и comment/reminders fetch — экспонируются через defineExpose в Vitest-тестах напрямую, дальнейшая декомпозиция требует изменения тест-контракта (отдельным flow). Layout-структуры (AppLayout 466, AuthLayout, AppShell) НЕ ТРОНУТЫ — R0.6 hard-стоп. Остальные 9 components >300 строк (AdminTenantDetailView, BillingView, AdminTenantsView, SecurityTab, RemindersView, ErrorView, DashboardView, ImpersonationDialog, далее) — вне scope Sprint 3, отдельным flow по запросу. vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success (1.15s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
2d7d7d1188 |
feat(frontend): Sprint 2 Phase B — Vue 3.5 defineModel + Vuetify 3.12 typed slots + lazy-imports + ESLint check
Sprint 2 Phase B (modernization). Закрытие audit O-stack-04/05/07 + O-perf-06:
- O-stack-04: Vue 3.5 defineModel() в 3 диалогах (NewDealDialog,
ImpersonationDialog, ReminderDialog) — boilerplate −5 строк/файл.
+ useTemplateRef() в TwoFactorView (input v-for refs).
- O-stack-05: Vuetify 3.12 типизированные слоты VDataTable
(DealsView + AdminTenantsView) — inline-аннотации `{ item }: { item: T }`
на 6+7 scoped-slot bindings; vue-tsc проверяет доступ к полям статически.
- O-stack-07: ESLint flat-config verified — header-comment добавлен
в eslint.config.js. Legacy .eslintrc.json не используется.
- O-perf-06: defineAsyncComponent() для тяжёлых диалогов в 3 местах:
DealsView (DealDetailDrawer + NewDealDialog), DealDetailDrawer
(ReminderDialog), RemindersView (ReminderDialog). KanbanView оставлен
sync — async-загрузка приводила к EnvironmentTeardownError в jsdom
(KanbanView.spec.ts), see in-file comment. Сборка показывает chunk'и
ImpersonationDialog (7.61 kB), DealDetailDrawer (11.12 kB), NewDealDialog
и ReminderDialog как отдельные lazy-bundles.
vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
cc6e1cba72 |
fix(configs): Windows-fix format:sql:check + lychee/pa11y/composer/ESLint hygiene + npm-outdated CI (audit P0-03 + 5 P1/O)
Закрытие аудита 2026-05-09 (
|
||
|
|
56195c8a59 |
fix(backend): tenant middleware на auth-routes + HasPasswordRules trait + test password (audit P0-01 + O-refactor-03 + P2-01)
Закрытие аудита 2026-05-09 (
|
||
|
|
cab1f87efd |
phase2(admin-tenant-detail-frontend): replace mock на real API в AdminTenantDetailView
- api/admin.ts +getAdminTenantDetail(subdomain) + 5 типов (ApiTenantUser/Project/
BalanceTx/ActivityEvent/Metrics + AdminTenantDetailResponse).
- composables/adminTenantDetailMapper.ts: mapAdminTenantDetail (API → mockTenantDetail
format). code=subdomain, deriveStatus (trial/overdue/suspended), deriveTariff
(Trial fallback), users (fullName из first+last||email, role='manager' хардкод —
schema users role нет, расширим в Post-MVP), projects (slug=tag), balanceHistory
(id префикс TX-, type-mapping для chargeback_*/trial_bonus/historical_import →
ближайший UI-эквивалент), activity (actor=actor_email||system, summary из
context.from→to), activitySinceText (relative time из last_activity_at).
- AdminTenantDetailView.vue: replace mock-lookup на async loadTenant + 3 ветки
template (loading / notFound / fetchError) + watch(code) для реактивной
навигации. inn/contact_phone/legal_address скрываются через v-if (нет в schema).
- AdminTenantDetailView.spec.ts переписан с MOCK на vi.mock('api/admin'):
13 тестов (вызов API с subdomain / organization_name+tariff / 4 KPI / KPI Лиды
todayActual/desired / Финансы tab / Пользователи tab / Проекты tab / Активность
tab с actor+summary / Войти как клиент / suspended disabled / 404 fallback /
500 fetch-error / overdue Просрочка / trial без оплаты).
- adminTenantDetailMapper.spec.ts +20 тестов: code/name/inn-empty/balanceRub
parse/mrrRub trial-null/status (4 ветки)/tariff (deriveTariff+fallback)/today
Actual+Desired/users (fullName / fallback)/projects/balanceHistory (TX- prefix +
chargeback type mapping)/activity (actor+summary)/metrics (4 поля)/activitySince.
- Vitest +23 (всего 416/416, +23 от 393).
Этап B эпика AdminTenantDetailView (frontend) ЗАКРЫТ. Эпик закрыт целиком (2 этапа).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
e0ffe7e686 |
phase2(reports-stage4): frontend integration ReportsView (replace mock)
- api/reports.ts: типизированные axios-helpers (listReportJobs/createReportJob/
retryReportJob/cancelReportJob/deleteReportJob) + ApiReportJob/Status/Format/
Counts/Quota interfaces. ensureCsrfCookie на mutating-вызовах.
- composables/reportsMapper.ts: mapApiReportJob (API → UI mock format с конверсией
pending→queued / processing→running). title строится на frontend'е (тип + period
с RU-месяцами «апр 2026» или диапазон «мар 2026 — апр 2026»). sizeText форматирует
bytes (B/KB/MB). timeText зависит от status (в очереди / в работе · Nс / N мин назад).
uiTypeToApi (deals → deals_export и т.д.).
- ReportsView.vue полностью переписан под API:
- onMounted → loadJobs (replace MOCK_JOBS на data из listReportJobs).
- usePolling 30 сек (фоновый авто-refresh).
- Submit → createReportJob → reload + success-alert + error-alert (validation+
общие ошибки извлекаются через extractValidationErrors/extractErrorMessage).
- canSubmit computed: disable если квота заполнена (active >= max).
- Reset-btn возвращает форму к defaults.
- Reload-btn (manual fast-path).
- Retry/Cancel/Download/Delete-кнопки → соответствующие API-вызовы;
Delete через v-dialog persistent confirm.
- fetch-error-alert на listReportJobs reject.
- Empty-state «Нет отчётов» когда jobs.length=0.
- canRetry проверяет retry_count<3 (max attempts CTO-6).
- Vitest +24 (всего 393/393, +24 от 369):
- reportsMapper.spec.ts +14: status mapping (pending/processing/done/failed) /
title (один месяц / диапазон) / format / sizeText (B/KB/MB/null) / attempt /
error / timeText (pending / processing / done «10 мин назад» / «только что») /
uiTypeToApi 4 slug'а / progress=50 для running.
- ReportsView.spec.ts переписан с MOCK_JOBS на vi.mock('api/reports') +12:
mount + listReportJobs called on mount / 4 type cards / default Сделки active /
4 формата / quota-banner из API / empty-state / done с Готов+Скачать /
failed с Ошибка+Повторить / failed retry_count=3 НЕ показывает Повторить /
pending с Отменить / Submit вызывает createReportJob+reload / Submit error →
submit-error-alert / Submit-btn disabled при квоте 3/3 / Reset / Reload-btn /
fetch-error-alert / Retry-btn / Cancel-btn / Delete confirm-dialog +
deleteReportJob.
Этап 4/4 эпика Reports backend ЗАКРЫТ. Эпик закрыт целиком.
Backend: 1 type (deals_export) × 4 формата (CSV/XLSX/JSON/PDF-stub).
Этап 2b (3 оставшихся типа: managers_summary/sources_summary/billing_summary)
— расширение через добавление 3 новых Provider-классов без изменений в архитектуре,
вынесено в Post-MVP backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
4c33323f0e |
phase2(notifications-stage6): low_balance + zero_balance + topup + invoice email/inapp
P0 этап 6 — 4 оставшихся email-события. Авто-план P0 (6 этапов) закрыт полностью: все 8 schema-default событий имеют рабочую интеграцию (new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid + заглушки для new_device_login/marketing). Backend: - 4 новых Mailable: LowBalanceNotification (threshold), ZeroBalanceNotification, TopupSuccessNotification (amountRub, amountLeads?), InvoicePaidNotification (amountRub, invoiceNumber?, tariffName?). - 4 blade-шаблона в emails/ (Forest-палитра, таблицы balance/amount/invoice). - NotificationService +4 методов: notifyLowBalance / notifyZeroBalance / notifyTopupSuccess / notifyInvoicePaid. Все шлют email + inapp по prefs. Интеграция в ProcessWebhookJob: - chargeNewLead после lead_charge: notifyLowBalance при пересечении порога сверху-вниз (balance_after <= threshold AND (balance_after+1) > threshold). Иначе спам при каждом lead_charge при balance < threshold. - logRejection(zero_balance): notifyZeroBalance ТОЛЬКО если в последний час не было другого RejectedDealsLog с тем же reason (anti-spam 1 email/час). Защита от self-just-inserted через id!= (timestamp-сравнение ненадёжно из-за PG microsecond precision). - topup_success / invoice_paid — service-методы готовы, integration после появления endpoints для пополнения (ЮKassa-webhook) и оплаты тарифа. - lowBalanceThreshold() читает system_settings.low_balance_threshold_leads (default 10, schema seed). Pest +12 в BalanceNotificationsTest (359/359 за 41.37 сек, 1233 assertions): - low_balance: пересечение порога / уже < threshold / > threshold / prefs.email=false (только inapp). - zero_balance: первое отклонение / 2-е в час не дублирует / >1ч снова шлёт. - topup_success / invoice_paid: notify создаёт email+inapp / prefs=email:false. - balance events изолированы между tenants. NewLeadNotificationTest: «balance=0 не шлёт» обновлён — Mail::assertNotSent(NewLeadNotification) вместо Mail::assertNothingSent (ZeroBalanceNotification теперь шлётся при balance=0 — новое поведение). PHPStan baseline регенерирован. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
dc1457a008 |
phase2(reminders-frontend): RemindersView + DealDetailDrawer + nav-badge
P0 этап 5 — frontend для reminders (после backend-этапа 4). Пользователь может создавать/просматривать/завершать/удалять напоминания из UI с inline-create в DealDetailDrawer. Frontend: - api/reminders.ts: типизированные helpers для 5 endpoints + ensureCsrfCookie для mutating. Types ReminderFilter/ApiReminder/ReminderCounts. - stores/reminders.ts: Pinia с items/counts/currentFilter + load/refreshCounts/create/update/complete/remove. Optimistic для complete/remove с revert на reject. - components/reminders/ReminderDialog.vue: dual-mode (create/edit) modal с native datetime-local input. Props dealId?/reminder? (edit), ISO-конверсия при submit. - views/RemindersView.vue: page-head с stats (active/overdue) + reload-btn; 4 tabs (today/upcoming/overdue/completed) с counts на бейджах (overdue=error color); v-list с complete-btn / dropdown Изменить/Удалить с confirm-dialog; empty-state. - router: /reminders маршрут (lazy). - AppLayout: nav-badge «Напоминания» биндит count из store (replace static «12»); скрыт при count=0; polling 60 сек для refreshCounts. - DealDetailDrawer: секция «Напоминания» (только при tenantId+deal): inline + create-btn / список / complete / встроенный ReminderDialog. Vitest +20 (369/369 за 21.20 сек): - reminders-store 11: initial / load+reject / refreshCounts / create+reject / complete optimistic+revert / remove+reject / reset. - RemindersView 7: mount / 4 tabs / counts / empty-state / список / reload-btn / filter=today default. - AppLayout +2: бейдж скрыт при count=0 / показывает count при >0. Pest 347/347 (без изменений — backend нетронут). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
01c20e7b6c |
phase2(polling): usePolling composable 30 сек + Page Visibility pause
Закрывает последний unblocked production-TODO «Polling/SSE для real-time».
Manual reload-btn остаётся как fast-path; polling — фоновый автообновитель.
Composable (composables/usePolling.ts):
- usePolling(loader, {intervalMs=30_000, enabled=true}).
- Page Visibility API: при document.hidden=true interval останавливается;
при visibilitychange с возвратом hidden=false — restart + немедленный
loader() (не ждать следующего interval'а).
- Cleanup на onBeforeUnmount: clearInterval + removeEventListener.
- enabled=false — composable не стартует (feature-flag).
Integration:
- DealsView + KanbanView → loadDeals.
- AdminTenantsView → loadTenants.
- AdminBillingView → loadBilling.
- AdminIncidentsView → loadIncidents.
Vitest +6 (usePolling.spec.ts) с vi.useFakeTimers:
- Вызов каждые intervalMs / default 30 сек / skip при document.hidden /
cleanup на unmount / enabled=false → no-op / visibilitychange
pause+resume с немедленным loader.
Регресс:
- Lint+type-check+format passed.
- Vitest 319/319 за 18.67 сек (+6 от 313).
- Vite build 899 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.62 сек (backend не тронут).
Реестр v1.71→v1.72 / CLAUDE.md v1.62→v1.63.
ВСЕ unblocked production-TODO закрыты.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |