# Удаление проектов вместо архива + дедуп источника + человеческие ошибки **Дата:** 2026-05-21 **Статус:** утверждён (заказчик «ок делай») **Ветка исполнения:** worktree от `feat/project-migration-redesign` (origin/main) ## Контекст и проблема Заказчик при создании проекта получил сырой `SQLSTATE[23505] Unique violation ... projects_tenant_id_name_key` в UI. Разбор вскрыл 4 связанных задачи: 1. **Архивация бессмысленна** — если клиенту проект не нужен, хранить его незачем. Сейчас «удаление» (`DELETE /api/projects/{id}`) = мягкая архивация (`ProjectService::archive` → `archived_at`, `is_active=false`). Нужно настоящее удаление. 2. **Шеринг между клиентами** — два разных клиента могут завести проекты с одинаковыми параметрами/источником. Это корректно (модель шеринга: один донор раздаётся ≤3 клиентам через `LeadRouter`). Менять не нужно — подтвердить. 3. **Дедуп источника внутри клиента отсутствует** — один клиент может завести 2 проекта на один источник (номер/домен/SMS-отправитель). Нужен запрет. 4. **Утечка SQL в UI** — `Project::create()` бьётся о DB-констрейнт без перехвата → сырой `SQLSTATE` рендерится пользователю. Нужны человеческие сообщения. ## Ключевые факты кодовой базы (разведка) - `deals.project_id` — **без FK** (таблица партиционирована, partition-wise FK не поддерживается). Hard-delete проекта НЕ каскадит и НЕ блокируется сделками → они «повиснут». Поэтому удаление проекта со сделками опасно → блокируем (см. Решение 1). - `ON DELETE CASCADE` на `projects(id)` имеют только служебные таблицы: `project_supplier_links`, `supplier_manual_sync_queue`, `project_suppliers`, `project_user_assignments`, `project_limit_adjustments`. Их каскад при удалении — норма. - Уникальность `projects` на DB-уровне: `projects_tenant_id_name_key` = `(tenant_id, name)`. Дедупа по источнику на DB-уровне нет. - Источник проекта: `signal_identifier` (call=телефон `7\d{10}`, site=домен) либо `sms_senders[]` (+ `sms_keyword`) для sms. - Шеринг донора: `project_supplier_links` (M:N projects↔supplier_projects) связывает Лидерра-проекты РАЗНЫХ тенантов с одним `supplier_project` (донором). Outbound-синк (`SyncSupplierProjectsJob`) считает агрегатный лимит по всем клиентам источника (`computeOrder = max(max, ceil(Σ/3))`, cap=3). - Архив-ссылки (под снос): backend — `ProjectController` (destroy/index/bulk), `ProjectService` (archive/bulk/update/resolveBulkScope), `Project` (scopeActive/scopeArchived, fillable+cast `archived_at`), `ProjectResource`, `DashboardController`, `SyncSupplierProjectsJob`, `BulkProjectActionRequest`. Frontend — `BulkActionsBar`, `ProjectCard`, `ProjectDetailsDrawer`, `ProjectsView`, `projectsStore`. ## Решения (утверждены заказчиком) ### Решение 1 — удаление вместо архива, с защитой по сделкам - `DELETE /api/projects/{id}` → **hard delete** через новый `ProjectService::delete()`. - **Guard:** если по проекту есть хоть одна `deals` (любой статус, включая `deleted_at`-soft) → удаление блокируется HTTP 422 (`{errors:{...}}`/`message`, формат фронта) с сообщением: *«Нельзя удалить проект: по нему есть сделки. Остановите приём (пауза), чтобы скрыть из работы».* Пустой проект (0 сделок) → удаляется насовсем. - Архивация убирается **полностью**: код-пути `archive`, scope `archived`, фильтр «Архивные», bulk-action `archive`. Колонка `archived_at` дропается миграцией (schema bump). Пауза (`is_active`) сохраняется — это отдельный механизм. - Bulk: action `archive` → `delete` (с тем же guard'ом per-project; проекты со сделками попадают в `skipped` с причиной, не роняют весь батч). ### Решение 2 — удаление у поставщика с учётом шеринга При удалении Лидерра-проекта P (тенант T) по источнику X (один или несколько доноров B1/B2/B3 через `project_supplier_links`): 1. Удаляем P локально (его `project_supplier_links` уходят каскадом). 2. Для каждого затронутого `supplier_project` S (донора источника X): - Считаем оставшиеся `project_supplier_links` на S (проекты ДРУГИХ тенантов). - **Остались** → донор нужен другим клиентам → **пере-синк** S (агрегатный лимит/регионы/дни без T) через outbound-синк. У поставщика проект НЕ удаляем. - **Не осталось** (T был последним) → у поставщика **удаляем** донора (`SupplierPortalClient::deleteProject(external_id)`) + удаляем локальную запись `supplier_projects` S. 3. Внешние вызовы к поставщику — через job (resilience, retry), не inline в HTTP-запросе. Граничные сценарии: - P не привязан ни к одному донору (например, синк ещё не прошёл) → шаг 2 пропускается. - Несколько доноров (B1/B2/B3) у одного источника → шаг 2 для каждого независимо. - Падение удаления у поставщика → job ретраит; локальное удаление P уже выполнено (eventual consistency; «висячий» донор у поставщика подметёт следующий синк/ретрай). ### Решение 3 — дедуп источника внутри клиента - При создании и при изменении источника: внутри `tenant_id` источник должен быть уникален среди проектов клиента. - «Источник» (source key): - call/site → `signal_identifier`; - sms → нормализованный `sms_senders` (сортировка+lower) + `sms_keyword`. - Enforcement: app-level проверка в `ProjectService::create()`/`update()` → 422 с сообщением *«У вас уже есть проект с этим источником: "<название>"»*. После сноса архива «существующие проекты» = все проекты клиента (soft-deleted проектов нет — мы их hard-delete'им). - DB-уровень: partial unique index как защита-эшелон (опционально, в той же миграции); его нарушение перехватывается общим обработчиком (Решение 4) и не утекает. ### Решение 4 — человеческие ошибки вместо SQL - App-level pre-checks (до DB): уникальность `name` в рамках клиента + уникальность источника (Решение 3) → 422 `{errors: {field: [msg]}}` (формат, который уже понимает фронт). - Глобальный перехват `Illuminate\Database\QueryException` в `bootstrap/app.php` (`withExceptions`): в лог — полный текст; пользователю — generic *«Не удалось сохранить. Проверьте данные или попробуйте ещё раз»* (HTTP 422 для JSON-запросов). Никакой `SQLSTATE` в UI. - Фронт `NewProjectDialog`/`projectsStore` — убедиться, что 422 `errors`/`message` показываются как есть (уже умеет; правок минимум). ## Затрагиваемые компоненты **Backend** - `ProjectService`: +`delete()` (guard сделок + оркестрация шеринга), −`archive()`, bulk `archive`→`delete`, чистка `archived_at`/`archived` из update/resolveBulkScope, +дедуп источника в create/update. - `ProjectController`: `destroy()` → `delete()`, index `status=archived` убрать, bulk doc. - `BulkProjectActionRequest`: `archive`→`delete`, status `archived` убрать. - `Project` (модель): −scopeArchived, scopeActive упростить/убрать, −`archived_at` fillable+cast. - `ProjectResource`: −`archived_at`. - `DashboardController`, `SyncSupplierProjectsJob`: убрать `whereNull('archived_at')`. - Новый job `DeleteSupplierProjectJob` (или расширение существующего) — удаление донора у поставщика, когда источник остался без потребителей. - Миграция: `DROP COLUMN projects.archived_at` (+ опц. partial unique index источника) → schema bump v8.27 + `db/CHANGELOG_schema.md`. - `bootstrap/app.php`: глобальный handler `QueryException`. **Frontend** - `BulkActionsBar`: «Архивировать»→«Удалить» (+подтверждение/иконка). - `ProjectCard`, `ProjectDetailsDrawer`: «Архивировать»→«Удалить». - `ProjectsView`: `@archive`→`@delete`, убрать фильтр «Архивные». - `projectsStore`: `archive()`→`delete()`, bulk `archive`→`delete`, тип `archived_at` убрать. ## Тестирование (TDD) - Backend (Pest): delete пустого проекта → 204 + строки нет; delete со сделками → 409/422, проект жив; шеринг — delete последнего потребителя → донор удалён у поставщика (mock client); delete при оставшихся потребителях → донор НЕ удалён, пере-синк; дедуп источника create/update → 422; имя-дубль → 422 (не SQL); глобальный QueryException handler → generic message. - Frontend (Vitest): кнопки «Удалить» вместо «Архивировать»; нет фильтра «Архивные»; store.delete дёргает DELETE; ошибка сервера показывается человеческим текстом. - Live («проверь на практике» по каждой задаче, затем чистка тестовых данных): 1) удаление пустого проекта + блок на проекте со сделками; 2) два тенанта с одинаковым источником — создание проходит; 3) попытка дубля источника у одного тенанта — отказ с понятным текстом; 4) создание дубля имени — человеческое сообщение, не SQL. ## Вне scope (YAGNI) - Restore/корзина удалённых проектов (удаление окончательное по решению заказчика). - Массовая авто-чистка уже-архивированных проектов dev-БД (разовая ручная операция). - `StatusPill 'archived'` mapping не трогаем (используется и для статусов сделок).