diff --git a/CLAUDE.md b/CLAUDE.md index 313ba0f1..ec475b1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.55 от 09.05.2026 +**Версия:** 1.56 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -15,7 +15,7 @@ | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.7 от 08.05.2026 поздний вечер — Histoire 1.0-beta.1 активирован, фаза 2 по тулчейну закрыта 6/6, всего активно 18/28) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | | Схема БД | [db/schema.sql](db/schema.sql) (**v8.8 от 09.05.2026** — `users.totp_secret` VARCHAR(255) → TEXT для encrypted-cast. Метрики: 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.64 от 09.05.2026 — **GET /api/lead-statuses + Pinia store**: LeadStatus model + LeadStatusController; `useLeadStatusesStore` с snapshot fallback + load() идемпотентен; DealsView/KanbanView/DealDetailDrawer перешли на store вместо static-snapshot; Pest 210/210 + Vitest 280/280 + Histoire 21/28) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.65 от 09.05.2026 — **PATCH /api/deals/{id} + comment-editor в drawer**: частичное редактирование (comment/manager_id/status); ActivityLog event на каждое изменение (commented/assigned/status_changed); textarea + Save в DealDetailDrawer + reload events; Pest 220/220 + Vitest 283/283 + Histoire 21/28) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.56 от 09.05.2026. Изменения v1.56: **PATCH /api/deals/{id} + comment-editor в DealDetailDrawer** — drawer переходит из read-only в редактируемый режим. **(1) Backend `DealController::update(int $id)`** — PATCH /api/deals/{id} с body `{tenant_id, comment?, manager_id?, status?}` (все поля optional, должен быть хотя бы один). Каждое изменённое поле пишет соответствующий ActivityLog event: `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 log. Manager FK guard (manager_id чужого tenant'а → 422) и status validation (slug должен существовать в lead_statuses → 422) — те же что в store/transition. RLS-обёртка + defense-in-depth `where(tenant_id)` → 404 для чужой сделки. Маршрут `Route::patch('/api/deals/{id}', 'update')->where('id', '[0-9]+')`. **(2) Pest +10** в `DealUpdateTest` (всего **220/220 за 25.64 сек**, 871 assertion): 422 без tenant_id / 404 unknown / 404 чужая сделка / comment update + deal.commented audit / manager update + deal.assigned audit + assigned_at=NOW / status update + deal.status_changed audit / 422 неизвестный slug + НЕ обновляет / 422 manager чужого tenant'а / NO-OP не пишет audit / комбинированно (comment+status одним запросом) → 2 audit log записи. **(3) Frontend `api/deals.ts::updateDeal(id, payload)`** — типизированный PATCH-helper с `ensureCsrfCookie` (mutating endpoint). **DealDetailDrawer:** добавлена секция «Комментарий» (показывается ТОЛЬКО при наличии tenantId — без auth остаётся read-only) с `v-textarea` (auto-grow, counter=5000, hide-details) + Save-btn `mdi-content-save-outline` (loading во время save). `commentDraft` (ref) populates из `getDeal` response (`deal.comment ?? ''`). `saveComment()` async вызывает `updateDeal` с `comment: commentDraft || null` + на success — toast «Комментарий сохранён» + reload events (новый `deal.commented` появляется в timeline); на fail — `commentSaveError=true` + warning toast «Не удалось сохранить — попробуйте позже». `v-snackbar` reuses `commentSaveError` для color=warning. **(4) Vitest +3** в `DealDetailDrawerApi.spec.ts` (всего **283/283 за 18.13 сек**): saveComment вызывает updateDeal с правильным payload + toast success + reload events (getDeal вызвался дважды); saveComment reject → commentSaveError=true + toast warning «Не удалось»; comment-section НЕ рендерится без tenantId (read-only mode для legacy local-режима). **PHPStan baseline регенерирован**. **Production TODO остаточные:** GET /api/admin/{tenants,billing,incidents} (этапы 2-4 текущего плана); soft-delete + DELETE /api/deals (этап 5, требует миграцию); polling/SSE; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **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 от 09.05.2026. Изменения v1.55: **GET /api/lead-statuses + Pinia store** — заменяет static-снапшот в коде на live-данные из БД (включая custom slug'и, добавленные после deployment'а). **(1) Backend** — `App\Models\LeadStatus` (PK=`slug` string, `incrementing=false`, `keyType='string'`, `timestamps=null`); `LeadStatusController::index` — GET /api/lead-statuses, ORDER BY sort_order+slug, формат `{slug, name_ru, is_system, sort_order, color_hex, description}`. Таблица глобальная (НЕ tenant-aware), auth не требуется на MVP. **(2) Pest +5** в `LeadStatusesIndexTest` (всего **210/210 за 24.59 сек**, 840 assertions): 200 + не пустой / все 14 системных slug'ов из seed (new..final_missed) / поля slug/name_ru/color_hex/sort_order/is_system / sort_order ASC / кастомный slug добавленный после seed возвращается. **(3) Frontend** — `api/leadStatuses.ts::listLeadStatuses` (GET helper); `stores/leadStatuses.ts::useLeadStatusesStore` Pinia setup-store: `statuses` ref (default = `LEAD_STATUSES` snapshot для UI без fetch'а), `load(force=false)` идемпотентен (повторный вызов → no-op если loaded), `bySlug` computed Map для O(1), `findBySlug(slug)` helper. На fail — snapshot остаётся, `fetchError=true`. **(4) Integration в 3 view-компонента:** DealsView заменил `LEAD_STATUSES` импорт на `leadStatusesStore.statuses` (computed `leadStatuses`) для bulk-status menu и `statusBySlug` (computed Map из store getter); KanbanView заменил на `leadStatuses` computed для column-iteration + count display + safe-access `dealsByStatus[slug] || []` в template (защита от custom slug'а из API без seeded column); DealDetailDrawer переписал `LEAD_STATUSES.find(...)` → `store.findBySlug(...)`. Оба view'а вызывают `leadStatusesStore.load()` в `onMounted` (рядом с loadDeals). **`reduce` для init `dealsByStatus`** в KanbanView оставлен на snapshot (всегда seeded 14; новые custom-колонки появятся после API-load — empty-array fallback в template). **(5) Vitest +7** в `leadStatusesStore.spec.ts` + 2 spec'а DealDetailDrawer'а получили `setActivePinia(createPinia())` в beforeEach (без этого `getActivePinia()` падает в jsdom): initial state snapshot / findBySlug returns existing / findBySlug null для unknown / load() success — replace + loaded=true / load() reject — fetchError + snapshot остаётся / load() идемпотентен (1 запрос на 2 вызова) / load(force=true) — 2 запроса. Всего **280/280 за 19.44 сек** (+7 от 273). **Production TODO остаточные:** polling/SSE для real-time обновления (на MVP — manual reload-btn); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 280/280 за 19.44 сек** (+7 от 273); vite build 1.17 сек (KanbanView lazy-chunk 182.22→182.28 KB — Pinia-getter overhead); Pint+PHPStan passed; **Pest 210/210 за 24.59 сек** (+5 от 205, 840 assertions). Реестр v1.63→v1.64.* *CLAUDE.md v1.54 от 09.05.2026. Изменения v1.54: **GET /api/deals/{id} + интеграция DealDetailDrawer на реальный ActivityLog**. **(1) Backend `DealController::show(int $id)`** — возвращает `{deal, events}` для drawer'а. RLS-обёртка + defense-in-depth `where(tenant_id)` (как в index/transition); 404 если сделка чужая или не существует. `deal` — extended (project_name + manager_name/initials через `ManagerController::format*` + comment + assigned_at). `events` — последние **50** записей `activity_log` фильтрованных по `(tenant_id, deal_id)` ORDER BY created_at DESC, с актором (user через `belongsTo`-relation). Маршрут `Route::get('/api/deals/{id}', 'show')->where('id', '[0-9]+')`. **(2) Pest +8** в `tests/Feature/DealShowTest.php` (всего **205/205 за 24.19 сек**, 812 assertions): 422 без tenant_id / 404 unknown tenant / 404 несуществующая сделка / 404 чужая сделка (RLS-проверка через postgres superuser BYPASSRLS работает за счёт app-фильтра) / deal-relations (project_name + manager_name «Иван П.» + initials «ИП» + comment) / events ORDER BY created_at DESC (status_changed свежее createde) + actor.name + actor=null для system-event с user_id=null / RLS+app-фильтр НЕ показывает события с `deal_id` совпадающим у чужого tenant'а / лимит 50 событий (60 записей → возвращаем 50). **(3) Frontend `api/deals.ts::getDeal(id, tenantId)`** — типизированный helper с `ApiDealEvent`/`ApiDealDetail`/`GetDealResponse` interfaces; БЕЗ ensureCsrfCookie (GET-only). **`composables/dealsApiMapper.ts::mapApiDealEvent(api, now=new Date())`** — converter ApiDealEvent → DealEvent (UI-формат): `event` slug clamp на known types (`deal.{created,status_changed,viewed,commented,assigned,balance_charged}`) с fallback на `'deal.viewed'` (generic-icon); `actor` маппится 1:1; `minutesAgo = max(0, floor((now - created_at) / 60_000))`; `detail` зависит от type — для `status_changed` строим `«from → to»` из context, для `created` — `«Лид принят (источник: …)»`, для остальных — JSON-сводка контекста. **(4) DealDetailDrawer** получил optional `tenantId` prop. `watch([open, deal.id, tenantId])` с `immediate: true` — на open=true вызывает `loadEvents()`. Если оба (deal + tenantId) есть → `getDeal(deal.id, tenantId)` → `events.value = events.map(mapApiDealEvent)`. На fail → `eventsFetchError=true` + `v-alert type=warning «Backend недоступен — показаны mock-события»` (data-testid=`events-fetch-error-alert`) + fallback на `MOCK_EVENTS`. Без tenantId — никогда не fetch'им, MOCK_EVENTS как раньше. DealsView и KanbanView передают `:tenant-id="auth.user?.tenant_id"`. **(5) Vitest +4** в `DealDetailDrawerApi.spec.ts` (всего **273/273 за 20.76 сек**, +4 от 269): без tenantId — getDeal не вызывается + MOCK_EVENTS видны / с tenantId — getDeal вызывается + events заменены + «new → paid» виден / reject → eventsFetchError + alert + MOCK_EVENTS fallback / open=false → НЕ вызывается. PHPStan baseline регенерирован для +новых ignored Pest TestCall warnings. **Production TODO остаточные:** polling/SSE для real-time обновления (на MVP — manual reload-btn); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 273/273 за 20.76 сек** (+4 от 269); vite build 1.12 сек (KanbanView lazy-chunk 182.17→182.22 KB — DealDetailDrawer импорт `mapApiDealEvent` shared); Pint+PHPStan passed; **Pest 205/205 за 24.19 сек** (+8 от 197, 812 assertions). Реестр v1.62→v1.63.* diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index 60516708..8b352d44 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -299,6 +299,139 @@ class DealController extends Controller ]); } + /** + * PATCH /api/deals/{id} — частичное редактирование сделки из DealDetailDrawer. + * + * Body (все поля optional, должно быть хотя бы одно): {tenant_id, comment?, + * manager_id?, status?}. + * + * Каждое изменение пишется в ActivityLog с правильным event-type: + * - comment → deal.commented (context.text — новый комментарий полностью) + * - manager_id → deal.assigned (context.from/to) + * - status → deal.status_changed (context.from/to/source='manual') + * + * NO-OP (значение не меняется) — ActivityLog НЕ пишется. + * + * Manager FK guard: новый manager_id должен принадлежать tenant'у (как в store). + * Status validation: slug должен существовать в lead_statuses (как в transition). + * + * RLS + defense-in-depth where(tenant_id) — 404 если сделка чужая. + */ + public function update(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'tenant_id' => 'required|integer|min:1', + 'comment' => 'nullable|string|max:5000', + 'manager_id' => 'nullable|integer|min:1', + 'status' => 'nullable|string|max:50', + ]); + + $tenant = Tenant::find($validated['tenant_id']); + if ($tenant === null) { + return response()->json(['message' => 'Тенант не найден.'], 404); + } + + // Validate status slug если передан. + if (array_key_exists('status', $validated) && $validated['status'] !== null) { + $statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists(); + if (! $statusExists) { + return response()->json([ + 'message' => 'Неизвестный статус.', + 'errors' => ['status' => ['Slug не найден в lead_statuses.']], + ], 422); + } + } + + // Manager FK guard. + if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) { + $managerExists = User::query() + ->where('id', $validated['manager_id']) + ->where('tenant_id', $tenant->id) + ->whereNull('deleted_at') + ->where('is_active', true) + ->exists(); + if (! $managerExists) { + return response()->json([ + 'message' => 'Менеджер не найден в этом тенанте.', + 'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']], + ], 422); + } + } + + $deal = DB::transaction(function () use ($validated, $tenant, $id) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); + + $deal = Deal::query() + ->where('tenant_id', $tenant->id) + ->where('id', $id) + ->first(); + + if ($deal === null) { + return null; + } + + // Применяем изменения с записью ActivityLog для каждого изменённого поля. + if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) { + $deal->comment = $validated['comment']; + ActivityLog::create([ + 'tenant_id' => $tenant->id, + 'user_id' => null, + 'deal_id' => $deal->id, + 'event' => 'deal.commented', + 'context' => ['text' => $validated['comment'] ?? ''], + ]); + } + + if (array_key_exists('manager_id', $validated) && $deal->manager_id !== $validated['manager_id']) { + $previousManager = $deal->manager_id; + $deal->manager_id = $validated['manager_id']; + $deal->assigned_at = $validated['manager_id'] !== null ? now() : null; + ActivityLog::create([ + 'tenant_id' => $tenant->id, + 'user_id' => null, + 'deal_id' => $deal->id, + 'event' => ActivityLog::EVENT_DEAL_ASSIGNED, + 'context' => ['from' => $previousManager, 'to' => $validated['manager_id']], + ]); + } + + if (array_key_exists('status', $validated) && $validated['status'] !== null && $deal->status !== $validated['status']) { + $previousStatus = $deal->status; + $deal->status = $validated['status']; + ActivityLog::create([ + 'tenant_id' => $tenant->id, + 'user_id' => null, + 'deal_id' => $deal->id, + 'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED, + 'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'], + ]); + } + + $deal->save(); + + return $deal; + }); + + if ($deal === null) { + return response()->json(['message' => 'Сделка не найдена.'], 404); + } + + return response()->json([ + 'deal' => [ + 'id' => $deal->id, + 'tenant_id' => $deal->tenant_id, + 'project_id' => $deal->project_id, + 'phone' => $deal->phone, + 'contact_name' => $deal->contact_name, + 'comment' => $deal->comment, + 'status' => $deal->status, + 'manager_id' => $deal->manager_id, + 'received_at' => $deal->received_at?->toIso8601String(), + 'assigned_at' => $deal->assigned_at?->toIso8601String(), + ], + ]); + } + /** POST /api/deals — manual create */ public function store(Request $request): JsonResponse { diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 9ff42e06..3c161f6e 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -75,55 +75,55 @@ parameters: - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#' identifier: property.notFound - count: 1 + count: 3 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#' identifier: property.notFound - count: 1 + count: 4 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$contact_name\.$#' identifier: property.notFound - count: 3 + count: 4 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#' identifier: property.notFound - count: 3 + count: 7 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phone\.$#' identifier: property.notFound - count: 3 + count: 4 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$project_id\.$#' identifier: property.notFound - count: 3 + count: 4 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$received_at\.$#' identifier: property.notFound - count: 4 + count: 5 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' identifier: property.notFound - count: 6 + count: 10 path: app/Http/Controllers/Api/DealController.php - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$tenant_id\.$#' identifier: property.notFound - count: 3 + count: 4 path: app/Http/Controllers/Api/DealController.php - @@ -798,6 +798,66 @@ parameters: count: 7 path: tests/Feature/DealTransitionTest.php + - + message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' + identifier: property.notFound + count: 6 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 10 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 24 + path: tests/Feature/DealUpdateTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#' + identifier: method.notFound + count: 10 + path: tests/Feature/DealUpdateTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#' identifier: property.notFound diff --git a/app/resources/js/api/deals.ts b/app/resources/js/api/deals.ts index d9ff7207..cc77d534 100644 --- a/app/resources/js/api/deals.ts +++ b/app/resources/js/api/deals.ts @@ -40,6 +40,19 @@ export interface ExportDealsPayload { format?: 'csv' | 'xlsx'; } +export interface UpdateDealPayload { + tenant_id: number; + comment?: string | null; + manager_id?: number | null; + status?: string; +} + +export async function updateDeal(id: number, payload: UpdateDealPayload): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.patch<{ deal: ApiDealDetail }>(`/api/deals/${id}`, payload); + return data.deal; +} + export interface TransitionDealsPayload { tenant_id: number; ids: number[]; diff --git a/app/resources/js/components/deals/DealDetailDrawer.vue b/app/resources/js/components/deals/DealDetailDrawer.vue index f7bdfa49..60da9ee3 100644 --- a/app/resources/js/components/deals/DealDetailDrawer.vue +++ b/app/resources/js/components/deals/DealDetailDrawer.vue @@ -61,9 +61,17 @@ const events = ref([...MOCK_EVENTS]); const eventsLoading = ref(false); const eventsFetchError = ref(false); +// Comment editor — редактирование текущего комментария сделки. +const commentDraft = ref(''); +const commentSaving = ref(false); +const commentSaveError = ref(false); +const commentToastOpen = ref(false); +const commentToastText = ref(''); + async function loadEvents() { if (!props.deal || !props.tenantId) { events.value = [...MOCK_EVENTS]; + commentDraft.value = ''; return; } eventsLoading.value = true; @@ -71,14 +79,38 @@ async function loadEvents() { try { const res = await dealsApi.getDeal(props.deal.id, props.tenantId); events.value = res.events.map((e) => mapApiDealEvent(e)); + commentDraft.value = res.deal.comment ?? ''; } catch { eventsFetchError.value = true; events.value = [...MOCK_EVENTS]; + commentDraft.value = ''; } finally { eventsLoading.value = false; } } +async function saveComment() { + if (!props.deal || !props.tenantId) return; + commentSaving.value = true; + commentSaveError.value = false; + try { + await dealsApi.updateDeal(props.deal.id, { + tenant_id: props.tenantId, + comment: commentDraft.value || null, + }); + commentToastText.value = 'Комментарий сохранён.'; + commentToastOpen.value = true; + // Reload events чтобы показать новый deal.commented в timeline. + await loadEvents(); + } catch { + commentSaveError.value = true; + commentToastText.value = 'Не удалось сохранить — попробуйте позже.'; + commentToastOpen.value = true; + } finally { + commentSaving.value = false; + } +} + // Fetch при открытии drawer'а или смене сделки. watch( () => [props.open, props.deal?.id, props.tenantId] as const, @@ -88,7 +120,18 @@ watch( { immediate: true }, ); -defineExpose({ events, eventsLoading, eventsFetchError, loadEvents }); +defineExpose({ + events, + eventsLoading, + eventsFetchError, + loadEvents, + commentDraft, + commentSaving, + commentSaveError, + commentToastOpen, + commentToastText, + saveComment, +}); diff --git a/app/routes/web.php b/app/routes/web.php index 52058141..d5c33a31 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -56,6 +56,7 @@ Route::get('/api/deals/{id}', [DealController::class, 'show'])->where('id', '[0- Route::post('/api/deals', [DealController::class, 'store']); Route::post('/api/deals/export', [DealController::class, 'export']); Route::post('/api/deals/transition', [DealController::class, 'transition']); +Route::patch('/api/deals/{id}', [DealController::class, 'update'])->where('id', '[0-9]+'); // Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters). Route::get('/api/managers', [ManagerController::class, 'index']); diff --git a/app/tests/Feature/DealUpdateTest.php b/app/tests/Feature/DealUpdateTest.php new file mode 100644 index 00000000..b30e74b2 --- /dev/null +++ b/app/tests/Feature/DealUpdateTest.php @@ -0,0 +1,170 @@ +tenant = Tenant::factory()->create(); + $this->otherTenant = Tenant::factory()->create(); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $this->project = Project::factory()->for($this->tenant)->create(); + $this->manager = User::factory()->for($this->tenant)->create(['is_active' => true]); +}); + +test('PATCH /api/deals/{id} 422 без tenant_id', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); + $this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(422); +}); + +test('PATCH /api/deals/{id} 404 unknown tenant', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); + $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => 999999, + 'comment' => 'X', + ])->assertStatus(404); +}); + +test('PATCH /api/deals/{id} 404 чужая сделка', function () { + DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id); + $foreignProject = Project::factory()->for($this->otherTenant)->create(); + $foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(); + + $this->patchJson('/api/deals/'.$foreign->id, [ + 'tenant_id' => $this->tenant->id, + 'comment' => 'leak', + ])->assertStatus(404); +}); + +test('PATCH /api/deals/{id} обновляет comment + пишет deal.commented в ActivityLog', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'comment' => 'Дозвонился, перезвоню после 14:00', + ]); + $r->assertStatus(200); + expect($r->json('deal.comment'))->toBe('Дозвонился, перезвоню после 14:00'); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $deal->refresh(); + expect($deal->comment)->toBe('Дозвонился, перезвоню после 14:00'); + + $log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.commented')->first(); + expect($log)->not->toBeNull(); + expect($log->context['text'])->toBe('Дозвонился, перезвоню после 14:00'); +}); + +test('PATCH /api/deals/{id} обновляет manager_id + пишет deal.assigned + ставит assigned_at', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create([ + 'manager_id' => null, + 'assigned_at' => null, + ]); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'manager_id' => $this->manager->id, + ]); + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $deal->refresh(); + expect($deal->manager_id)->toBe($this->manager->id); + expect($deal->assigned_at)->not->toBeNull(); + + $log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.assigned')->first(); + expect($log)->not->toBeNull(); + expect($log->context['to'])->toBe($this->manager->id); +}); + +test('PATCH /api/deals/{id} обновляет status + пишет deal.status_changed source=manual', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'status' => 'paid', + ]); + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $deal->refresh(); + expect($deal->status)->toBe('paid'); + + $log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first(); + expect($log)->not->toBeNull(); + expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']); +}); + +test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'status' => 'not_a_real_slug', + ]); + $r->assertStatus(422); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $deal->refresh(); + expect($deal->status)->toBe('new'); // не изменился +}); + +test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', function () { + DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id); + $foreignManager = User::factory()->for($this->otherTenant)->create(['is_active' => true]); + + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'manager_id' => $foreignManager->id, + ]); + $r->assertStatus(422); +}); + +test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create([ + 'status' => 'paid', + 'comment' => 'same', + ]); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'status' => 'paid', // не меняем + 'comment' => 'same', // не меняем + ]); + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + expect(ActivityLog::where('deal_id', $deal->id)->count())->toBe(0); +}); + +test('PATCH /api/deals/{id} комбинированно — comment + status одним запросом → 2 ActivityLog', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create([ + 'status' => 'new', + 'comment' => null, + ]); + + $r = $this->patchJson('/api/deals/'.$deal->id, [ + 'tenant_id' => $this->tenant->id, + 'comment' => 'Заметка', + 'status' => 'worked', + ]); + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $events = ActivityLog::where('deal_id', $deal->id)->orderBy('id')->get(); + expect($events)->toHaveCount(2); + $eventNames = $events->pluck('event')->all(); + expect($eventNames)->toContain('deal.commented'); + expect($eventNames)->toContain('deal.status_changed'); +}); diff --git a/app/tests/Frontend/DealDetailDrawerApi.spec.ts b/app/tests/Frontend/DealDetailDrawerApi.spec.ts index c67d6f72..d0ebd87b 100644 --- a/app/tests/Frontend/DealDetailDrawerApi.spec.ts +++ b/app/tests/Frontend/DealDetailDrawerApi.spec.ts @@ -12,6 +12,7 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => { return { ...orig, getDeal: vi.fn(), + updateDeal: vi.fn(), }; }); @@ -115,4 +116,99 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => { await flushPromises(); expect(dealsApi.getDeal).not.toHaveBeenCalled(); }); + + it('saveComment вызывает updateDeal + toast success + reload events', async () => { + const apiResponse: GetDealResponse = { + deal: { + id: MOCK_DEALS[0].id, + tenant_id: 1, + project_id: 5, + project_name: 'Окна', + phone: '+7 (999) 100-00-01', + contact_name: 'Anna', + comment: 'old', + status: 'new', + manager_id: null, + manager_name: null, + manager_initials: null, + received_at: new Date().toISOString(), + assigned_at: null, + }, + events: [], + }; + vi.mocked(dealsApi.getDeal).mockResolvedValue(apiResponse); + vi.mocked(dealsApi.updateDeal).mockResolvedValueOnce({ + ...apiResponse.deal, + comment: 'новая заметка', + }); + + const wrapper = factory({ open: true, tenantId: 1 }); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + commentDraft: string; + saveComment: () => Promise; + commentToastOpen: boolean; + commentToastText: string; + commentSaveError: boolean; + }; + expect(vm.commentDraft).toBe('old'); // загружено из getDeal + vm.commentDraft = 'новая заметка'; + await vm.saveComment(); + await flushPromises(); + + expect(dealsApi.updateDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, { + tenant_id: 1, + comment: 'новая заметка', + }); + expect(vm.commentToastOpen).toBe(true); + expect(vm.commentToastText).toContain('сохранён'); + expect(vm.commentSaveError).toBe(false); + // reload events после save — getDeal вызвался ещё раз. + expect(dealsApi.getDeal).toHaveBeenCalledTimes(2); + }); + + it('saveComment reject → toast warning + commentSaveError=true', async () => { + vi.mocked(dealsApi.getDeal).mockResolvedValueOnce({ + deal: { + id: MOCK_DEALS[0].id, + tenant_id: 1, + project_id: 5, + project_name: null, + phone: '+7 (999)', + contact_name: null, + comment: null, + status: 'new', + manager_id: null, + manager_name: null, + manager_initials: null, + received_at: null, + assigned_at: null, + }, + events: [], + }); + vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500')); + + const wrapper = factory({ open: true, tenantId: 1 }); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + commentDraft: string; + saveComment: () => Promise; + commentSaveError: boolean; + commentToastText: string; + }; + vm.commentDraft = 'новый'; + await vm.saveComment(); + await flushPromises(); + + expect(vm.commentSaveError).toBe(true); + expect(vm.commentToastText).toContain('Не удалось'); + }); + + it('comment-section не показывается без tenantId (read-only mode)', async () => { + const wrapper = factory({ open: true }); + await flushPromises(); + expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false); + }); }); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 0542d12b..79e07c97 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,60 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.64 от 09.05.2026 — **GET /api/lead-statuses + Pinia store** после deal-show v1.63: новый endpoint возвращает live-данные lead_statuses (включая кастомные); `useLeadStatusesStore` с snapshot fallback и идемпотентным load(); DealsView/KanbanView/DealDetailDrawer переехали со static-импорта `LEAD_STATUSES` на store. **Pest 210/210 (840 assertions) + Vitest 280/280 + Histoire 21/28 зелёные**. +**Версия:** 1.65 от 09.05.2026 — **PATCH /api/deals/{id} + comment-editor в DealDetailDrawer** после lead-statuses v1.64 (этап 1/5 авто-плана): drawer переходит из read-only в editable; ActivityLog event на каждое изменение (commented/assigned/status_changed); textarea + Save в drawer + auto-reload events. **Pest 220/220 (871 assertion) + Vitest 283/283 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.65 относительно v1.64:** + +- **(1) Backend `DealController::update(int $id)`** — PATCH /api/deals/{id}: + - Body: `{tenant_id, comment?, manager_id?, status?}` — все поля optional, должно быть хотя бы одно изменение. + - Каждое изменённое поле пишет соответствующий ActivityLog event: + - `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 log. + - Manager FK guard + status slug validation (как в store/transition). + - RLS + defense-in-depth `where(tenant_id)` → 404 для чужой сделки. + +- **(2) Pest +10** в DealUpdateTest: + 1. 422 без tenant_id; 2. 404 unknown tenant; 3. 404 чужая сделка; + 2. comment update + deal.commented audit; + 3. manager update + deal.assigned audit + assigned_at=NOW; + 4. status update + deal.status_changed audit; + 5. 422 неизвестный slug + НЕ обновляет; + 6. 422 manager чужого tenant'а; + 7. NO-OP не пишет audit (status старый==новый, comment старый==новый); + 8. Комбинированно (comment+status одним запросом) → 2 audit log записи. + +- **(3) Frontend integration:** + - `api/deals.ts::updateDeal(id, payload)` — PATCH helper с `ensureCsrfCookie`. + - **DealDetailDrawer**: новая секция «Комментарий» (показывается только при tenantId — read-only mode для legacy local). + - `v-textarea` auto-grow + counter=5000 + commentDraft ref populates из getDeal response. + - Save-btn (loading state) → `updateDeal` с `comment: commentDraft || null`. + - На success → toast «Комментарий сохранён» + auto-reload events (новый `deal.commented` появляется в timeline). + - На fail → `commentSaveError=true` + warning toast «Не удалось сохранить». + +- **(4) Vitest +3** в DealDetailDrawerApi: + - saveComment вызывает updateDeal + toast success + reload events (getDeal called twice). + - saveComment reject → commentSaveError=true + warning toast. + - comment-section НЕ рендерится без tenantId. + +- **PHPStan baseline регенерирован**. + +- **Регресс зелёный:** + - `npm run lint:vue` + `type-check` + `format` — passed. + - `npm run test:vue` — **283/283 за 18.13 сек** (+3 от 280). + - `npm run build` — vite OK 1.12 сек. + - `composer pint` + `composer stan` — passed. + - `composer test` — **Pest 220/220 за 25.64 сек** (+10 от 210, 871 assertion). + +- **Что НЕ сделано (production TODO остаточные после v1.65):** + - **Этапы 2-4** текущего авто-плана: GET /api/admin/{tenants,billing,incidents} + интеграция в Admin*View. + - **Этап 5** авто-плана: soft-delete migration + DELETE /api/deals (требует ALTER TABLE deals + bump schema). + - Polling/SSE для real-time (на MVP — manual reload-btn). + - **#6 Yandex 360 SSO** ⏸ ждёт Б-1. + - **#7 Pest browser-mode** — отложен (инфра). + +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2). **Что изменилось в v1.64 относительно v1.63:**