# Удаление проектов вместо архива + дедуп источника + человеческие ошибки

**Дата:** 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 не трогаем (используется и для статусов сделок).
