b053796182
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
12 KiB
Markdown
150 lines
12 KiB
Markdown
# Удаление проектов вместо архива + дедуп источника + человеческие ошибки
|
||
|
||
**Дата:** 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 не трогаем (используется и для статусов сделок).
|