diff --git a/CLAUDE.md b/CLAUDE.md index c33decb5..7ac9c2bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.60 от 09.05.2026 +**Версия:** 1.61 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -15,7 +15,7 @@ | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.7 от 08.05.2026 поздний вечер — Histoire 1.0-beta.1 активирован, фаза 2 по тулчейну закрыта 6/6, всего активно 18/28) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | | Схема БД | [db/schema.sql](db/schema.sql) (**v8.9 от 09.05.2026** — `deals.deleted_at TIMESTAMPTZ` для bulk soft-delete UI applyBulkDelete + partial index `(tenant_id, status) WHERE deleted_at IS NULL`. Метрики: 55 таблиц + 12 партиций + 93 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.69 от 09.05.2026 — **soft-delete + DELETE /api/deals** (этап 5/5 — авто-план закрыт): schema v8.8→v8.9 + Deal SoftDeletes trait + global scope; ActivityLog event=deal.deleted; DealsView::applyBulkDelete async + optimistic + toast; Pest 256/256 + Vitest 308/308 + Histoire 21/28) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.70 от 09.05.2026 — **bulk restore-flow** (этап 6 — completion of stage 5): POST /api/deals/restore + ActivityLog `deal.restored`; undo-snackbar (8 сек) с кнопкой «Восстановить» в DealsView после bulk-delete; Pest 263/263 + Vitest 311/311 + Histoire 21/28) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.61 от 09.05.2026. Изменения v1.61: **Bulk restore-flow** — completion of stage 5 (soft-delete был half-done без undo-кнопки). **(1) Backend `DealController::restore`** — POST /api/deals/restore body `{tenant_id, ids: [1..1000 ints]}`. Использует `Deal::query()->withTrashed()` чтобы обойти global scope SoftDeletes + явный `whereNotNull('deleted_at')` для NO-OP idempotency на уже живых сделках. RLS + defense-in-depth `where(tenant_id)` → партиальный update только своих. ActivityLog event=deal.restored, context.source='bulk' для каждой ВОССТАНОВЛЕННОЙ. **`ActivityLog::EVENT_DEAL_RESTORED`** константа добавлена в model. Маршрут `Route::post('/api/deals/restore')`. **(2) Pest +7** в `DealRestoreTest` (всего **263/263 за 27.68 сек**, 998 assertions): 422 / 404 unknown / soft-delete + restore + audit / NO-OP на живых не пишет audit / defense-in-depth (свой восстановлен, чужой остался удалён) / после restore сделка снова видна в GET /api/deals / 422 пустой массив. **(3) Frontend `dealsApi.bulkRestoreDeals(payload)`** — POST-helper. **DealsView::applyBulkDelete** расширен: snapshot удалённых сделок (deep-clone manager.* nested object) сохраняется в `lastDeletedSnapshot` ref для undo. `undoBulkDelete()` async: optimistic re-insert через `dealsState.unshift` + `bulkRestoreDeals` если auth.user; на success — toast «Восстановлено N из M.»; на fail — warning. **v-snackbar** для bulk-delete увеличен с 3 до 8 сек + получил `#actions` слот с кнопкой «Восстановить» (показывается только если `lastDeletedSnapshot.length > 0`). После успешного undo snapshot очищается → кнопка пропадает. **(4) Vitest +3** в `DealsListIntegration.spec.ts` (всего **311/311 за 18.71 сек**, +3 от 308): bulk-delete + undo восстанавливает обе сделки + bulkRestoreDeals вызывается с правильными ids + lastDeletedSnapshot очищается; undo без tenant_id — bulkRestoreDeals НЕ вызывается + только локальное восстановление; undo reject → warning toast + локальное восстановление остаётся. **PHPStan baseline регенерирован**. **Production TODO остаточные:** polling/SSE; mrr_rub aggregate в /api/admin/tenants; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **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 от 09.05.2026. Изменения v1.60: **soft-delete + DELETE /api/deals** (этап 5/5 — авто-план **закрыт полностью**). **(1) Schema v8.8 → v8.9** — `deals.deleted_at TIMESTAMPTZ` (NULL = живая сделка) + partial index `(tenant_id, status) WHERE deleted_at IS NULL` (самый частый UI-фильтр). ALTER TABLE на партиционированной `deals` распределяет колонку во все 6 партиций автоматически (PG 14+). CHANGELOG_schema.md +§U с обоснованием soft-delete vs hard (CASCADE-FK от webhook_dedup_keys уничтожил бы dedup-ключи и нарушил идемпотентность §5.5). Метрики: 92→93 индекса. **(2) Backend `DealController::destroy`** — DELETE /api/deals body `{tenant_id, ids: [1..1000 ints]}`. Bulk-update `deleted_at=NOW()` через RLS+defense-in-depth `where(tenant_id)`. Каждая удалённая сделка пишет `ActivityLog event=deal.deleted, context.source='bulk'`. NO-OP (уже удалена) НЕ пишет audit. `Deal` model получил `SoftDeletes` trait + `deleted_at` в fillable+casts — global scope автоматически добавляет `whereNull('deleted_at')` ко всем существующим query'ам (index/show/transition/update/export), без явного фильтра. Маршрут `Route::delete('/api/deals')`. **(3) Pest +8** в `DealDestroyTest` (всего **256/256 за 27.75 сек**, 977 assertions): 422/404 базовые / soft-delete + ActivityLog deal.deleted+source=bulk / defense-in-depth (свой удалён, чужой жив) / NO-OP idempotency (повторное удаление не пишет audit) / GET /api/deals скрывает soft-deleted / GET /api/deals/{id} 404 для soft-deleted / 422 пустой массив. **Quirk:** `migrate:fresh --env=testing` без `.env.testing` файла использовал `liderra` вместо `liderra_testing` — тесты падали на «column deleted_at не существует»; решение `DB_DATABASE=liderra_testing php artisan migrate:fresh` (без --env). **(4) Frontend `dealsApi.bulkDeleteDeals(payload)`** — DELETE-helper с `axios.delete('/api/deals', { data: payload })` (axios особенность: DELETE с body передаётся через `config.data`, не `payload`). **DealsView::applyBulkDelete** переписан async: optimistic local-removal (UI отвечает сразу) + `bulkDeleteDeals` если auth.user; на success — toast «Удалено N из M.»; на fail — warning toast «Не удалось удалить — изменения только локально.» + локальный update НЕ откатывается (UX-paradigma как у applyBulkStatus). Без auth — только optimistic (legacy local-mode). **(5) Vitest +3** в `DealsListIntegration.spec.ts` (всего **308/308 за 20.12 сек**, +3 от 305): bulkDeleteDeals с tenant_id + optimistic + toast «Удалено 2» / без tenant_id — НЕ вызывается / reject → warning toast + локальный update остаётся. **PHPStan baseline регенерирован**. **АВТО-ПЛАН (5 этапов) ЗАКРЫТ ПОЛНОСТЬЮ.** **Production TODO остаточные (после v1.60):** polling/SSE для real-time (на MVP — manual reload-btn); restore-flow для soft-deleted сделок (POST /api/deals/{id}/restore — отдельный коммит); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра; tenants-tariff helpers (mrr_rub в schema через JOIN на tariff_plans). **Регресс зелёный:** lint+type-check+format ✅; **Vitest 308/308 за 20.12 сек** (+3 от 305); vite build 973 ms; Pint+PHPStan passed; **Pest 256/256 за 27.75 сек** (+8 от 248, 977 assertions). Реестр v1.68→v1.69.* *CLAUDE.md v1.59 от 09.05.2026. Изменения v1.59: **GET /api/admin/incidents + AdminIncidentsView API integration** (этап 4/5). **(1) Backend `AdminIncidentsController::index`** — GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= по schema §9 `incidents_log`. ORDER BY started_at DESC. **Derived поля** в response: `incident_id` (формат `INC-YYYY-MMDD-NNNN` — год+месяц+день started_at + zero-padded id); `status` (resolved/investigating/open — derive из resolved_at/detected_at); `affected_tenants_count` (из BIGINT[] array — parsePgArray для PG-литерала); `rkn_deadline_at` (для type=data_breach без rkn_notified_at: detected_at+24h по 152-ФЗ). **`summary`** считает {open, investigating, rkn_pending, total_unresolved} 4 отдельными SELECT'ами. **(2) Pest +11** в `AdminIncidentsIndexTest` (всего **248/248 за 28.02 сек**, 951 assertion): пустой / поля + incident_id формат / derive статус (investigating/resolved) / type filter / severity filter / unresolved_only / ORDER BY started_at DESC / data_breach имеет rkn_deadline +24h / non-data_breach НЕ имеет deadline / summary.rkn_pending (только PDN-breach без notification) / limit+offset. **Quirk:** schema saas_admin_users использует `full_name` (не `first_name`/`last_name`) + не имеет updated_at — сразу исправлено в helper insert. **(3) Frontend** — `api/admin.ts::listAdminIncidents(params)` с типизированными ApiAdminIncident/Summary/Response (severity narrowed на enum, остальное — string). **AdminIncidentsView** переписан: новый `IncidentRow` interface унифицирует mock и API форму (mock-`category` ↔ API-`type`, mock-`title` ↔ API-`summary`); reactive `rowsState` (default = ADMIN_INCIDENTS) + `stats`; loadIncidents() async на onMounted замещает mock на API; на fail — fetchError + warning alert + MOCK fallback; reload-btn. Maps категорий (categoryMap/statusInfo/severityInfo) переписаны на функции с fallback'ами на новые slug'и. РКН pending chip учитывает оба варианта `pdn_breach`/`data_breach`. **(4) Vitest +5** в `AdminIncidentsViewApi.spec.ts` (всего **305/305 за 20.59 сек**, +5 от 300): listAdminIncidents на mount / replace rowsState + summary с rkn_deadline сохранением / reject → fetchError + alert + MOCK fallback / reload-btn двойной вызов / РКН pending chip отображается для data_breach без rkn_notified. **PHPStan baseline регенерирован**. **Production TODO остаточные:** этап 5 (soft-delete migration + DELETE /api/deals); polling/SSE; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 305/305 за 20.59 сек** (+5 от 300); vite build 1.05 сек; Pint+PHPStan passed; **Pest 248/248 за 28.02 сек** (+11 от 237, 951 assertion). Реестр v1.67→v1.68.* diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index de5ab0bf..d2102673 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -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 { diff --git a/app/app/Models/ActivityLog.php b/app/app/Models/ActivityLog.php index 24bda97d..98ac7602 100644 --- a/app/app/Models/ActivityLog.php +++ b/app/app/Models/ActivityLog.php @@ -28,6 +28,8 @@ class ActivityLog extends Model public const EVENT_DEAL_DELETED = 'deal.deleted'; + public const EVENT_DEAL_RESTORED = 'deal.restored'; + public $timestamps = false; protected $table = 'activity_log'; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 830aadaa..0a81f6fc 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -201,7 +201,7 @@ parameters: - message: '#^Access to an undefined property App\\Models\\Deal\:\:\$deleted_at\.$#' identifier: property.notFound - count: 1 + count: 2 path: app/Http/Controllers/Api/DealController.php - @@ -906,6 +906,48 @@ parameters: count: 1 path: tests/Feature/DealModelTest.php + - + message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/DealRestoreTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/DealRestoreTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/DealRestoreTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 18 + path: tests/Feature/DealRestoreTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/DealRestoreTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/DealRestoreTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/DealRestoreTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#' identifier: property.notFound diff --git a/app/resources/js/api/deals.ts b/app/resources/js/api/deals.ts index 5fac503e..706e5057 100644 --- a/app/resources/js/api/deals.ts +++ b/app/resources/js/api/deals.ts @@ -52,6 +52,23 @@ export async function bulkDeleteDeals(payload: BulkDeleteDealsPayload): Promise< return data; } +export interface BulkRestoreDealsPayload { + tenant_id: number; + ids: number[]; +} + +export interface BulkRestoreDealsResponse { + restored: number; + requested: number; +} + +/** Bulk restore soft-deleted сделок. NO-OP idempotent для не-удалённых. */ +export async function bulkRestoreDeals(payload: BulkRestoreDealsPayload): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/deals/restore', payload); + return data; +} + export interface ExportDealsPayload { tenant_id: number; ids: number[]; diff --git a/app/resources/js/views/DealsView.vue b/app/resources/js/views/DealsView.vue index 7dcf452b..842b5c7a 100644 --- a/app/resources/js/views/DealsView.vue +++ b/app/resources/js/views/DealsView.vue @@ -88,6 +88,9 @@ const statusToastOpen = ref(false); const statusToastText = ref(''); const deleteToastOpen = ref(false); const deleteToastText = ref(''); +// Snapshot для undo последнего bulk-delete (auto-clear через timeout snackbar'а). +const lastDeletedIds = ref([]); +const lastDeletedSnapshot = ref([]); // New-deal dialog state const newDealOpen = ref(false); @@ -130,27 +133,80 @@ async function applyBulkDelete() { const ids = [...selected.value]; deleteConfirmOpen.value = false; - // Optimistic local-removal — UI отвечает сразу. + // Snapshot удалённых сделок для undo (deep clone — manager.* nested object). + const removed: MockDeal[] = []; const idSet = new Set(ids); for (let i = dealsState.length - 1; i >= 0; i--) { - if (idSet.has(dealsState[i].id)) dealsState.splice(i, 1); + if (idSet.has(dealsState[i].id)) { + removed.unshift({ ...dealsState[i], manager: { ...dealsState[i].manager } }); + dealsState.splice(i, 1); + } } selected.value = []; - if (!auth.user?.tenant_id || ids.length === 0) return; + if (!auth.user?.tenant_id || ids.length === 0) { + // Local-only mode — undo тоже только local. + if (removed.length > 0) { + lastDeletedIds.value = ids; + lastDeletedSnapshot.value = removed; + deleteToastText.value = `Удалено ${removed.length} сделок (локально).`; + deleteToastOpen.value = true; + } + return; + } try { const res = await dealsApi.bulkDeleteDeals({ tenant_id: auth.user.tenant_id, ids, }); + // Сохраняем для undo (последний bulk-delete). + lastDeletedIds.value = ids; + lastDeletedSnapshot.value = removed; deleteToastText.value = `Удалено ${res.deleted} из ${res.requested}.`; } catch { + // Локальный update НЕ откатываем (UX-pattern). Undo тоже доступен — + // пользователь восстановит на backend если он позже стал доступен. + lastDeletedIds.value = ids; + lastDeletedSnapshot.value = removed; deleteToastText.value = 'Не удалось удалить — изменения только локально.'; } deleteToastOpen.value = true; } +async function undoBulkDelete() { + const ids = lastDeletedIds.value; + const snapshot = lastDeletedSnapshot.value; + if (ids.length === 0 || snapshot.length === 0) return; + + // Optimistic — возвращаем сделки в state. + snapshot.forEach((deal) => { + if (!dealsState.find((d) => d.id === deal.id)) { + dealsState.unshift(deal); + } + }); + lastDeletedIds.value = []; + lastDeletedSnapshot.value = []; + deleteToastOpen.value = false; + + if (!auth.user?.tenant_id) { + deleteToastText.value = `Восстановлено ${snapshot.length} сделок (локально).`; + deleteToastOpen.value = true; + return; + } + + try { + const res = await dealsApi.bulkRestoreDeals({ + tenant_id: auth.user.tenant_id, + ids, + }); + deleteToastText.value = `Восстановлено ${res.restored} из ${res.requested}.`; + } catch { + deleteToastText.value = 'Не удалось восстановить — изменения только локально.'; + } + deleteToastOpen.value = true; +} + async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') { if (selected.value.length === 0) { exportToastText.value = 'Нет выбранных сделок.'; @@ -266,6 +322,9 @@ defineExpose({ statusToastText, deleteToastOpen, deleteToastText, + lastDeletedIds, + lastDeletedSnapshot, + undoBulkDelete, }); const leadStatuses = computed(() => leadStatusesStore.statuses); @@ -612,8 +671,13 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai {{ statusToastText }} - + {{ deleteToastText }} + diff --git a/app/routes/web.php b/app/routes/web.php index e1b58ed0..376c698c 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -70,6 +70,7 @@ Route::post('/api/deals/export', [DealController::class, 'export']); Route::post('/api/deals/transition', [DealController::class, 'transition']); Route::patch('/api/deals/{id}', [DealController::class, 'update'])->where('id', '[0-9]+'); Route::delete('/api/deals', [DealController::class, 'destroy']); +Route::post('/api/deals/restore', [DealController::class, 'restore']); // Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters). Route::get('/api/managers', [ManagerController::class, 'index']); diff --git a/app/tests/Feature/DealRestoreTest.php b/app/tests/Feature/DealRestoreTest.php new file mode 100644 index 00000000..14368e5e --- /dev/null +++ b/app/tests/Feature/DealRestoreTest.php @@ -0,0 +1,135 @@ +tenant = Tenant::factory()->create(); + $this->otherTenant = Tenant::factory()->create(); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $this->project = Project::factory()->for($this->tenant)->create(); +}); + +test('POST /api/deals/restore 422 без обязательных полей', function () { + $this->postJson('/api/deals/restore', [])->assertStatus(422); +}); + +test('POST /api/deals/restore 404 на unknown tenant', function () { + $r = $this->postJson('/api/deals/restore', [ + 'tenant_id' => 999999, + 'ids' => [1], + ]); + $r->assertStatus(404); +}); + +test('POST /api/deals/restore восстанавливает soft-deleted + пишет deal.restored', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); + + // Удалим сначала + $this->deleteJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$deal->id], + ])->assertStatus(200); + + // Восстановим + $r = $this->postJson('/api/deals/restore', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$deal->id], + ]); + $r->assertStatus(200)->assertJson([ + 'restored' => 1, + 'requested' => 1, + ]); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + expect(DB::table('deals')->where('id', $deal->id)->value('deleted_at'))->toBeNull(); + + $log = ActivityLog::where('deal_id', $deal->id) + ->where('event', 'deal.restored') + ->first(); + expect($log)->not->toBeNull(); + expect($log->context)->toMatchArray(['source' => 'bulk']); +}); + +test('POST /api/deals/restore NO-OP для не-удалённых (живых) сделок', function () { + $alive = Deal::factory()->for($this->tenant)->for($this->project)->create(); + + $r = $this->postJson('/api/deals/restore', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$alive->id], + ]); + $r->assertStatus(200)->assertJson([ + 'restored' => 0, + 'requested' => 1, + ]); + + // Не пишем audit для NO-OP. + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + expect(ActivityLog::where('deal_id', $alive->id)->where('event', 'deal.restored')->count())->toBe(0); +}); + +test('POST /api/deals/restore defense-in-depth не восстанавливает чужие сделки', function () { + DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id); + $foreignProject = Project::factory()->for($this->otherTenant)->create(); + $foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(); + $foreign->delete(); // soft-delete + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $own = Deal::factory()->for($this->tenant)->for($this->project)->create(); + $own->delete(); + + $r = $this->postJson('/api/deals/restore', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$own->id, $foreign->id], + ]); + $r->assertStatus(200)->assertJson([ + 'restored' => 1, + 'requested' => 2, + ]); + + // Свой жив (deleted_at=NULL). + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + expect(DB::table('deals')->where('id', $own->id)->value('deleted_at'))->toBeNull(); + + // Чужой остался удалённым. + DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id); + expect(DB::table('deals')->where('id', $foreign->id)->value('deleted_at'))->not->toBeNull(); +}); + +test('POST /api/deals/restore — после restore сделка снова видна в GET /api/deals', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); + + // Удалили + $this->deleteJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$deal->id], + ])->assertStatus(200); + + // GET не возвращает + expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(0); + + // Restore + $this->postJson('/api/deals/restore', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$deal->id], + ])->assertStatus(200); + + // GET снова возвращает + expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(1); +}); + +test('POST /api/deals/restore 422 пустой массив ids', function () { + $this->postJson('/api/deals/restore', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [], + ])->assertStatus(422); +}); diff --git a/app/tests/Frontend/DealsListIntegration.spec.ts b/app/tests/Frontend/DealsListIntegration.spec.ts index 3ed20dc2..af93ff15 100644 --- a/app/tests/Frontend/DealsListIntegration.spec.ts +++ b/app/tests/Frontend/DealsListIntegration.spec.ts @@ -19,6 +19,7 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => { exportDeals: vi.fn(), exportDealsXlsx: vi.fn(), bulkDeleteDeals: vi.fn(), + bulkRestoreDeals: vi.fn(), }; }); @@ -317,10 +318,7 @@ describe('DealsView ↔ GET /api/deals integration', () => { it('applyBulkDelete с tenant_id вызывает bulkDeleteDeals + optimistic local-removal', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ - deals: [ - makeApiDeal({ id: 800, status: 'new' }), - makeApiDeal({ id: 801, status: 'new' }), - ], + deals: [makeApiDeal({ id: 800, status: 'new' }), makeApiDeal({ id: 801, status: 'new' })], total: 2, limit: 200, offset: 0, @@ -405,6 +403,114 @@ describe('DealsView ↔ GET /api/deals integration', () => { expect(vm.deleteToastText).toContain('Не удалось'); }); + it('bulk-delete + undo восстанавливает сделки + вызывает bulkRestoreDeals', async () => { + setupAuth(1); + vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ + deals: [makeApiDeal({ id: 1000, contact_name: 'A' }), makeApiDeal({ id: 1001, contact_name: 'B' })], + total: 2, + limit: 200, + offset: 0, + }); + vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ + deleted: 2, + requested: 2, + }); + vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({ + restored: 2, + requested: 2, + }); + + const wrapper = await mountDealsView(); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + selected: number[]; + applyBulkDelete: () => Promise; + undoBulkDelete: () => Promise; + dealsState: { id: number }[]; + lastDeletedSnapshot: { id: number }[]; + deleteToastText: string; + }; + + // Удаляем + vm.selected = [1000, 1001]; + await flushPromises(); + await vm.applyBulkDelete(); + await flushPromises(); + + expect(vm.dealsState.find((d) => d.id === 1000)).toBeUndefined(); + expect(vm.lastDeletedSnapshot).toHaveLength(2); + + // Undo + await vm.undoBulkDelete(); + await flushPromises(); + + expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [1000, 1001] }); + expect(vm.dealsState.find((d) => d.id === 1000)).toBeDefined(); + expect(vm.dealsState.find((d) => d.id === 1001)).toBeDefined(); + expect(vm.lastDeletedSnapshot).toHaveLength(0); // cleared after undo + expect(vm.deleteToastText).toContain('Восстановлено 2'); + }); + + it('undoBulkDelete без tenant_id — только локально, bulkRestoreDeals НЕ вызывается', async () => { + setupAuth(null); + const wrapper = await mountDealsView(); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + selected: number[]; + applyBulkDelete: () => Promise; + undoBulkDelete: () => Promise; + dealsState: { id: number }[]; + lastDeletedSnapshot: { id: number }[]; + }; + + const sample = vm.dealsState[0]; + vm.selected = [sample.id]; + await flushPromises(); + await vm.applyBulkDelete(); + await flushPromises(); + expect(vm.dealsState.find((d) => d.id === sample.id)).toBeUndefined(); + + await vm.undoBulkDelete(); + await flushPromises(); + + expect(dealsApi.bulkRestoreDeals).not.toHaveBeenCalled(); + expect(vm.dealsState.find((d) => d.id === sample.id)).toBeDefined(); + }); + + it('undoBulkDelete reject → warning toast, локальное восстановление остаётся', async () => { + setupAuth(1); + vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ + deals: [makeApiDeal({ id: 1100 })], + total: 1, + limit: 200, + offset: 0, + }); + vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 1, requested: 1 }); + vi.mocked(dealsApi.bulkRestoreDeals).mockRejectedValueOnce(new Error('500')); + + const wrapper = await mountDealsView(); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + selected: number[]; + applyBulkDelete: () => Promise; + undoBulkDelete: () => Promise; + dealsState: { id: number }[]; + deleteToastText: string; + }; + vm.selected = [1100]; + await flushPromises(); + await vm.applyBulkDelete(); + await flushPromises(); + await vm.undoBulkDelete(); + await flushPromises(); + + expect(vm.dealsState.find((d) => d.id === 1100)).toBeDefined(); // optimistic + expect(vm.deleteToastText).toContain('Не удалось восстановить'); + }); + it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ diff --git a/cspell-words.txt b/cspell-words.txt index 84e8615a..d8d27543 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -800,3 +800,5 @@ DTO vnd spreadsheetml MMDD +unshift +партиальный diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index fe703f23..f6cd6b79 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,53 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.69 от 09.05.2026 — **soft-delete + DELETE /api/deals** (этап 5/5 — авто-план **закрыт полностью**): schema v8.8→v8.9 с `deals.deleted_at` + partial index; Deal SoftDeletes trait с global scope; ActivityLog event=deal.deleted; DealsView::applyBulkDelete async + optimistic + toast. **Pest 256/256 (977 assertions) + Vitest 308/308 + Histoire 21/28 зелёные**. +**Версия:** 1.70 от 09.05.2026 — **bulk restore-flow** (completion of stage 5): POST /api/deals/restore + ActivityLog `deal.restored`; undo-snackbar (8 сек) с кнопкой «Восстановить» в DealsView после bulk-delete. **Pest 263/263 (998 assertions) + Vitest 311/311 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.70 относительно v1.69:** + +- **(1) Backend `DealController::restore`** — POST /api/deals/restore: + - Body: `{tenant_id, ids: [1..1000 ints]}`. + - `Deal::query()->withTrashed()` обходит global scope SoftDeletes; явный `whereNotNull('deleted_at')` обеспечивает NO-OP idempotency на уже живых сделках. + - RLS + defense-in-depth `where(tenant_id)` → партиальный restore только своих. + - Каждая ВОССТАНОВЛЕННАЯ сделка пишет ActivityLog event=deal.restored, context.source='bulk'. + - `ActivityLog::EVENT_DEAL_RESTORED` константа добавлена в model. + +- **(2) Pest +7** в DealRestoreTest: + 1. 422 без полей; 2. 404 unknown tenant; + 2. soft-delete + restore + ActivityLog deal.restored; + 3. NO-OP idempotency на живых сделках (НЕ пишет audit); + 4. defense-in-depth (свой восстановлен, чужой остался удалён); + 5. После restore сделка снова видна в GET /api/deals; + 6. 422 пустой массив ids. + +- **(3) Frontend integration:** + - `dealsApi.bulkRestoreDeals(payload)` — POST-helper. + - **DealsView::applyBulkDelete** расширен: snapshot удалённых сделок (deep-clone manager.* nested) сохраняется в `lastDeletedSnapshot` ref. + - `undoBulkDelete()` async: optimistic re-insert через `dealsState.unshift` + bulkRestoreDeals если auth.user; success → toast «Восстановлено N из M.»; fail → warning. + - **v-snackbar** для bulk-delete: timeout 3→8 сек + `#actions` слот с кнопкой «Восстановить» (показывается только при `lastDeletedSnapshot.length > 0`). + - После успешного undo snapshot очищается → кнопка пропадает. + +- **(4) Vitest +3** в DealsListIntegration: + 1. bulk-delete + undo восстанавливает обе сделки + bulkRestoreDeals вызывается с правильными ids + lastDeletedSnapshot очищается; + 2. undo без tenant_id — bulkRestoreDeals НЕ вызывается + только локальное восстановление; + 3. undo reject → warning toast + локальное восстановление остаётся. + +- **PHPStan baseline регенерирован**. + +- **Регресс зелёный:** + - `npm run lint:vue` + `type-check` + `format` — passed. + - `npm run test:vue` — **311/311 за 18.71 сек** (+3 от 308). + - `npm run build` — vite OK 877 ms. + - `composer pint` + `composer stan` — passed. + - `composer test` — **Pest 263/263 за 27.68 сек** (+7 от 256, 998 assertions). + +- **Что НЕ сделано (production TODO остаточные после v1.70):** + - Polling/SSE для real-time (на MVP — manual reload-btn). + - mrr_rub aggregate в /api/admin/tenants (требует JOIN tariff_plans). + - **#6 Yandex 360 SSO** ⏸ ждёт Б-1. + - **#7 Pest browser-mode** — отложен (инфра). + +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2). **Что изменилось в v1.69 относительно v1.68:**