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>
This commit is contained in:
Дмитрий
2026-05-09 09:10:58 +03:00
parent e31ea5354a
commit 7e1bf8b42d
9 changed files with 623 additions and 13 deletions
+4 -2
View File
@@ -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<LeadStatus[]> (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.*
@@ -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
{
+69 -9
View File
@@ -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
+13
View File
@@ -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<ApiDealDetail> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ deal: ApiDealDetail }>(`/api/deals/${id}`, payload);
return data.deal;
}
export interface TransitionDealsPayload {
tenant_id: number;
ids: number[];
@@ -61,9 +61,17 @@ const events = ref<DealEvent[]>([...MOCK_EVENTS]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
// Comment editor редактирование текущего комментария сделки.
const commentDraft = ref<string>('');
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,
});
</script>
<template>
@@ -158,6 +201,35 @@ defineExpose({ events, eventsLoading, eventsFetchError, loadEvents });
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Активность</h3>
<v-alert
@@ -196,6 +268,16 @@ defineExpose({ events, eventsLoading, eventsFetchError, loadEvents });
</li>
</ul>
</section>
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
</div>
</v-navigation-drawer>
</template>
+1
View File
@@ -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']);
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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');
});
@@ -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<void>;
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<void>;
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);
});
});
+54 -1
View File
@@ -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:**