phase2(restore-flow): POST /api/deals/restore + undo-snackbar (этап 6 — completion of stage 5)

Soft-delete был half-done: пользователь не мог отменить случайное удаление.
Теперь после bulk-delete показывается snackbar «Удалено N · Восстановить»
на 8 секунд.

Backend (DealController::restore):
- POST /api/deals/restore {tenant_id, ids: [1..1000 ints]}.
- withTrashed() обходит global scope SoftDeletes + явный
  whereNotNull('deleted_at') для NO-OP idempotency на живых.
- RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.restored, context.source='bulk' для каждой
  ВОССТАНОВЛЕННОЙ. Константа EVENT_DEAL_RESTORED добавлена в модель.

Pest +7 (DealRestoreTest):
- 422/404 базовые / soft-delete + restore + audit / NO-OP на живых
  не пишет audit / defense-in-depth (свой restored, чужой остался) /
  после restore видна в GET /api/deals / 422 пустой массив.

Frontend:
- dealsApi.bulkRestoreDeals — POST-helper.
- DealsView::applyBulkDelete: snapshot удалённых сделок (deep-clone
  manager.*) сохраняется в lastDeletedSnapshot ref.
- undoBulkDelete() async: optimistic re-insert + bulkRestoreDeals если
  auth.user; success → toast «Восстановлено N»; fail → warning.
- v-snackbar bulk-delete: 3→8 сек timeout + #actions слот с кнопкой
  «Восстановить» (показ только при snapshot.length > 0). После undo
  snapshot очищается → кнопка пропадает.

Vitest +3 (DealsListIntegration):
- bulk-delete + undo восстанавливает обе + bulkRestoreDeals + cleanup
  snapshot.
- Undo без tenant_id — НЕ вызывает API + только локально.
- Undo reject → warning toast + локальное восстановление остаётся.

PHPStan baseline регенерирован. cspell-glossary +unshift +партиальный.

Регресс:
- Lint+type-check+format passed.
- Vitest 311/311 за 18.71 сек (+3 от 308).
- Vite build 877 ms.
- Pint + PHPStan passed.
- Pest 263/263 за 27.68 сек (+7 от 256, 998 assertions).

Реестр v1.69→v1.70 / CLAUDE.md v1.60→v1.61.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-09 10:01:35 +03:00
parent f0dce283a8
commit c34d4009d1
11 changed files with 491 additions and 12 deletions
@@ -496,6 +496,68 @@ class DealController extends Controller
]);
}
/**
* POST /api/deals/restore bulk restore soft-deleted сделок.
*
* Body: {tenant_id, ids: [int...]}.
* Используется в DealsView undo-snackbar после applyBulkDelete (UX:
* пользователь нажал «Восстановить» в течение 8 сек после удаления).
*
* Соединяет SoftDeletes.withTrashed() с явным `whereNotNull('deleted_at')`
* restore только удалённых сделок (NO-OP idempotency для уже живых).
*
* RLS + defense-in-depth where(tenant_id) партиальный update только своих.
* ActivityLog event=deal.restored для каждой ВОССТАНОВЛЕННОЙ сделки.
*/
public function restore(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$restored = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
// withTrashed() обходит global scope SoftDeletes; явный
// whereNotNull — для NO-OP idempotency на уже живых сделках.
$deals = Deal::query()
->withTrashed()
->where('tenant_id', $tenant->id)
->whereIn('id', $validated['ids'])
->whereNotNull('deleted_at')
->get();
$count = 0;
foreach ($deals as $deal) {
$deal->deleted_at = null;
$deal->save();
ActivityLog::create([
'tenant_id' => $tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_RESTORED,
'context' => ['source' => 'bulk'],
]);
$count++;
}
return $count;
});
return response()->json([
'restored' => $restored,
'requested' => count($validated['ids']),
]);
}
/** POST /api/deals — manual create */
public function store(Request $request): JsonResponse
{