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

12 KiB
Raw Blame History

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

Дата: 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::archivearchived_at, is_active=false). Нужно настоящее удаление.
  2. Шеринг между клиентами — два разных клиента могут завести проекты с одинаковыми параметрами/источником. Это корректно (модель шеринга: один донор раздаётся ≤3 клиентам через LeadRouter). Менять не нужно — подтвердить.
  3. Дедуп источника внутри клиента отсутствует — один клиент может завести 2 проекта на один источник (номер/домен/SMS-отправитель). Нужен запрет.
  4. Утечка SQL в UIProject::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 archivedelete (с тем же 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 archivedelete, чистка archived_at/archived из update/resolveBulkScope, +дедуп источника в create/update.
  • ProjectController: destroy()delete(), index status=archived убрать, bulk doc.
  • BulkProjectActionRequest: archivedelete, 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 archivedelete, тип 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 не трогаем (используется и для статусов сделок).