phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.
Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.
Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
batch update 3 сделок + 3 ActivityLog с правильным context /
NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
только свой) / 422 пустой массив ids.
Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
N из M.», на fail — warning toast + локальный update НЕ откатывается.
Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.
Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.
PHPStan baseline регенерирован. cspell-glossary +апдейт*.
Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).
Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,87 @@ class DealController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/deals/transition — bulk status-update для выбранных сделок.
|
||||
*
|
||||
* Body: {tenant_id, ids: [int...], status: slug}.
|
||||
* Используется в DealsView bulk-actions (sticky-bar «Сменить статус»).
|
||||
*
|
||||
* Валидация:
|
||||
* - status должен существовать в lead_statuses (slug).
|
||||
* - ids проверяются на принадлежность tenant'у через `where(tenant_id)`.
|
||||
*
|
||||
* RLS + defense-in-depth: SET LOCAL + явный where(tenant_id) — в тестах
|
||||
* через postgres superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
|
||||
* что чужие id'шники не апдейтятся.
|
||||
*
|
||||
* На MVP без проверки allowed-переходов между статусами (любой → любой);
|
||||
* production: валидировать через lead_status_transitions (если введём).
|
||||
* История смен пишется в ActivityLog как `deal.status_changed`.
|
||||
*/
|
||||
public function transition(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'status' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
|
||||
if (! $statusExists) {
|
||||
return response()->json([
|
||||
'message' => 'Неизвестный статус.',
|
||||
'errors' => ['status' => ['Slug не найден в lead_statuses.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($validated, $tenant) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$deals = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($deals as $deal) {
|
||||
if ($deal->status === $validated['status']) {
|
||||
continue; // не пишем NO-OP в ActivityLog
|
||||
}
|
||||
$previous = $deal->status;
|
||||
$deal->status = $validated['status'];
|
||||
$deal->save();
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null, // на prod — request()->user()->id
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => [
|
||||
'from' => $previous,
|
||||
'to' => $validated['status'],
|
||||
'source' => 'bulk',
|
||||
],
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'updated' => $updated,
|
||||
'requested' => count($validated['ids']),
|
||||
'status' => $validated['status'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/deals — manual create */
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user