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
@@ -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
{