b053796182
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
Удаление проектов вместо архива + дедуп источника + человеческие ошибки
Дата: 2026-05-21
Статус: утверждён (заказчик «ок делай»)
Ветка исполнения: worktree от feat/project-migration-redesign (origin/main)
Контекст и проблема
Заказчик при создании проекта получил сырой SQLSTATE[23505] Unique violation ... projects_tenant_id_name_key в UI. Разбор вскрыл 4 связанных задачи:
- Архивация бессмысленна — если клиенту проект не нужен, хранить его незачем.
Сейчас «удаление» (
DELETE /api/projects/{id}) = мягкая архивация (ProjectService::archive→archived_at,is_active=false). Нужно настоящее удаление. - Шеринг между клиентами — два разных клиента могут завести проекты с одинаковыми
параметрами/источником. Это корректно (модель шеринга: один донор раздаётся ≤3 клиентам
через
LeadRouter). Менять не нужно — подтвердить. - Дедуп источника внутри клиента отсутствует — один клиент может завести 2 проекта на один источник (номер/домен/SMS-отправитель). Нужен запрет.
- Утечка 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+castarchived_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, scopearchived, фильтр «Архивные», bulk-actionarchive. Колонкаarchived_atдропается миграцией (schema bump). Пауза (is_active) сохраняется — это отдельный механизм. - Bulk: action
archive→delete(с тем же guard'ом per-project; проекты со сделками попадают вskippedс причиной, не роняют весь батч).
Решение 2 — удаление у поставщика с учётом шеринга
При удалении Лидерра-проекта P (тенант T) по источнику X (один или несколько доноров
B1/B2/B3 через project_supplier_links):
- Удаляем P локально (его
project_supplier_linksуходят каскадом). - Для каждого затронутого
supplier_projectS (донора источника X):- Считаем оставшиеся
project_supplier_linksна S (проекты ДРУГИХ тенантов). - Остались → донор нужен другим клиентам → пере-синк S (агрегатный лимит/регионы/дни без T) через outbound-синк. У поставщика проект НЕ удаляем.
- Не осталось (T был последним) → у поставщика удаляем донора
(
SupplierPortalClient::deleteProject(external_id)) + удаляем локальную записьsupplier_projectsS.
- Считаем оставшиеся
- Внешние вызовы к поставщику — через 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.
- call/site →
- 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— убедиться, что 422errors/messageпоказываются как есть (уже умеет; правок минимум).
Затрагиваемые компоненты
Backend
ProjectService: +delete()(guard сделок + оркестрация шеринга), −archive(), bulkarchive→delete, чисткаarchived_at/archivedиз update/resolveBulkScope, +дедуп источника в create/update.ProjectController:destroy()→delete(), indexstatus=archivedубрать, bulk doc.BulkProjectActionRequest:archive→delete, statusarchivedубрать.Project(модель): −scopeArchived, scopeActive упростить/убрать, −archived_atfillable+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: глобальный handlerQueryException.
Frontend
BulkActionsBar: «Архивировать»→«Удалить» (+подтверждение/иконка).ProjectCard,ProjectDetailsDrawer: «Архивировать»→«Удалить».ProjectsView:@archive→@delete, убрать фильтр «Архивные».projectsStore:archive()→delete(), bulkarchive→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 («проверь на практике» по каждой задаче, затем чистка тестовых данных):
- удаление пустого проекта + блок на проекте со сделками;
- два тенанта с одинаковым источником — создание проходит;
- попытка дубля источника у одного тенанта — отказ с понятным текстом;
- создание дубля имени — человеческое сообщение, не SQL.
Вне scope (YAGNI)
- Restore/корзина удалённых проектов (удаление окончательное по решению заказчика).
- Массовая авто-чистка уже-архивированных проектов dev-БД (разовая ручная операция).
StatusPill 'archived'mapping не трогаем (используется и для статусов сделок).