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>
This commit is contained in:
Дмитрий
2026-05-09 08:21:50 +03:00
parent a1ea003642
commit cba76c5d18
12 changed files with 572 additions and 10 deletions
@@ -132,6 +132,92 @@ class DealController extends Controller
]);
}
/**
* GET /api/deals/{id}?tenant_id={id} детали сделки + recent activity events.
*
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
* relations + до 50 последних activity_log событий по этой сделке.
*
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
* принадлежит tenant'у (или не существует) 404.
*/
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$deal = Deal::query()
->where('tenant_id', $tenantId)
->where('id', $id)
->with(['project:id,name', 'manager:id,email,first_name,last_name'])
->first();
if ($deal === null) {
return [null, []];
}
$events = ActivityLog::query()
->where('tenant_id', $tenantId)
->where('deal_id', $id)
->with('user:id,email,first_name,last_name')
->orderByDesc('created_at')
->orderByDesc('id')
->limit(50)
->get();
return [$deal, $events];
});
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,
'project_name' => $deal->project?->name,
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
'status' => $deal->status,
'manager_id' => $deal->manager_id,
'manager_name' => $deal->manager
? ManagerController::formatName($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email)
: null,
'manager_initials' => $deal->manager
? ManagerController::formatInitials($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email)
: null,
'received_at' => $deal->received_at?->toIso8601String(),
'assigned_at' => $deal->assigned_at?->toIso8601String(),
],
'events' => $events->map(fn (ActivityLog $e) => [
'id' => $e->id,
'event' => $e->event,
'context' => $e->context,
'created_at' => $e->created_at?->toIso8601String(),
'actor' => $e->user
? [
'id' => $e->user->id,
'name' => ManagerController::formatName($e->user->first_name, $e->user->last_name, $e->user->email),
'initials' => ManagerController::formatInitials($e->user->first_name, $e->user->last_name, $e->user->email),
]
: null,
]),
]);
}
/**
* POST /api/deals/transition bulk status-update для выбранных сделок.
*