Files
portal/docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md
T
Дмитрий b053796182 docs(spec): project delete (vs archive) + source dedup + human errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:27:59 +03:00

150 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Удаление проектов вместо архива + дедуп источника + человеческие ошибки
**Дата:** 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 не трогаем (используется и для статусов сделок).