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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user