Тема
ТЗ — Клиентский кабинет B2B (cms/cabinet-b2b)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Надстройка над cms/cabinet-b2c для корпоративных закупок: компания и сотрудники с ролями внутри компании, документы для юрлица, согласование заказов по лимиту суммы.
- Иерархия «компания → сотрудники»: несколько пользователей на один аккаунт организации;
- роли внутри компании: администратор, закупщик, согласующий, бухгалтер, наблюдатель;
- приглашение сотрудников по email со сроком действия ссылки, принятие/отклонение;
- заказы от юрлица с привязкой к компании и сотруднику-инициатору;
- счета и акты через
cms/commerce-invoices, договоры и лимиты черезcms/commerce-b2b; - согласование заказов: workflow «заявка сотрудника → одобрение согласующим при превышении лимита → оформление»;
- регистрация компании с автозаполнением реквизитов по ИНН/КПП через
suggest-provider(cms/dadata); - блокировка компании (например, за долг/нарушение) с корректной деградацией активных сессий сотрудников;
- выгрузка для бухгалтерии (реестр счетов/актов за период).
Зависимости и выключение
requires: cms/cabinet-b2c · suggests: cms/commerce-invoices (счета/акты), cms/commerce-b2b (договорные лимиты/цены), cms/dadata (suggest-provider — ИНН/КПП/адрес юрлица при регистрации компании)
Секции кабинета (карточка компании, список сотрудников, очередь согласований) встраиваются в UI cms/cabinet-b2c не отдельным layout поверх кабинета, а регистрацией через канонический provides-контракт cabinet-shell (CabinetSectionContract, легализован ревизией №2); аутентификация/профиль/сессии сотрудника — отдельным сервис-вызовом requires к cms/cabinet-b2c (см. «Таблица взаимодействий»).
Поведение при выключении: кабинет работает в B2C-режиме без компании и согласования — сотрудники видят только свои личные заказы, роли внутри компании и лимиты игнорируются. Деградация по каждому suggests-модулю — см. «Крайние случаи».
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_b2b_companies | id, name, inn, kpp, legal_address, credit_limit, status, blocked_reason, blocked_at | карточка организации; status — PHP Enum (active/blocked/suspended) |
cms_b2b_employees | id, company_id, user_id, role, status, spending_limit, invited_by | сотрудник компании с ролью, лимитом и статусом (active/deactivated) |
cms_b2b_invitations | id, company_id, email, role, token_hash, invited_by, expires_at, status | приглашение сотрудника; status — PHP Enum (pending/accepted/declined/expired/revoked) |
cms_b2b_order_approvals | id, order_id, company_id, requested_by, approved_by, status, limit_snapshot | согласование заказа сверх лимита; limit_snapshot — лимит на момент заявки |
FK company_id, user_id, order_id — constrained()->index(). Уникальный составной индекс (company_id, user_id) в cms_b2b_employees — сотрудник не дублируется в одной компании. Уникальный частичный индекс (company_id, email) в cms_b2b_invitations там, где status = 'pending' — повторное приглашение обновляет существующую заявку, не дублирует. role — PHP Enum (администратор/закупщик/согласующий/бухгалтер/наблюдатель); status в cms_b2b_order_approvals — PHP Enum. token_hash — хешированный токен приглашения (сырой токен только в письме, в БД не хранится).
ПДн-паспорт. ПДн: inn/kpp/legal_address компании (реквизиты юрлица, не физлица, но привязаны к контактным сотрудникам), email в cms_b2b_invitations (персональные данные приглашённого до принятия — до этого момента он ещё не пользователь ядра). Срок хранения: реквизиты компании — пока договор/аккаунт активен + срок хранения бухгалтерских документов по законодательству (вне зоны модуля, см. cms/commerce-invoices); cms_b2b_invitations с истёкшим/отклонённым статусом — 90 дней, затем purge. Модуль реализует хук ядра «забыть по запросу» для email неподтверждённых приглашений; данные сотрудника-физлица (профиль) обрабатываются хуками cms/cabinet-b2c, эта таблица лишь ссылается на user_id.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
Регистрация компании (POST /api/v1/cabinet/b2b/company) | name, inn, kpp?, legal_address | FormRequest: формат ИНН/КПП (контрольная сумма), автозаполнение/сверка через suggest-provider при подключённом cms/dadata |
Приглашение сотрудника (POST /api/v1/cabinet/b2b/employees/invite) | email, role, spending_limit? | FormRequest whitelist: role — enum ролей компании, email формат, лимит max_pending_invitations_per_company |
Принятие приглашения (POST /api/v1/cabinet/b2b/invitations/{token}/accept) | token (path) | токен не истёк, status = pending, email совпадает с аккаунтом принимающего |
Отклонение приглашения (POST /api/v1/cabinet/b2b/invitations/{token}/decline) | token (path) | токен не истёк, status = pending |
Деактивация сотрудника (DELETE /api/v1/cabinet/b2b/employees/{id}) | id (path) | scoped-запрос WHERE company_id = $currentCompany->id; запрет деактивации последнего администратора без передачи роли |
Передача роли администратора (POST /api/v1/cabinet/b2b/employees/{id}/transfer-admin) | id (path) | только текущий администратор, целевой сотрудник — активен |
Изменение лимита сотрудника (PUT /api/v1/cabinet/b2b/employees/{id}) | role?, spending_limit? | FormRequest whitelist, только cabinet-b2b.manage.own |
Согласование заказа (POST /api/v1/cabinet/b2b/orders/{id}/approve) | id (path), comment? | согласующий принадлежит компании заказа, заявка в статусе pending |
Отклонение заказа (POST /api/v1/cabinet/b2b/orders/{id}/reject) | id (path), reason | то же + причина обязательна |
Всё, что не перечислено выше, модуль обязан отвергать (whitelist-принцип §11 стандарта): произвольные роли вне enum, чужой company_id в пути/теле запроса.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| React-кабинет (Inertia, зона 3) | карточка компании, список сотрудников, очередь согласований | REST-конверт {data, meta} |
cms/commerce-orders | лимит/необходимость согласования при оформлении заказа | сервис-вызов (requires со стороны заказов) |
cms/commerce-invoices (при включении) | реестр счетов/актов для выгрузки бухгалтерии | сервис-вызов, экспорт CSV/PDF |
NotificationDispatch (ядро) | письмо-приглашение сотруднику, уведомление о блокировке компании | вызов контракта, log-fallback без notification-channel |
Filament (cabinet-b2b.manage, studio) | карточка компании с сотрудниками, очередь согласований, статус блокировки | admin-view |
cms/audit (если включён) | деактивация сотрудника, блокировка/разблокировка компании, передача роли администратора | событие → аудит-лог |
Настройки (группа cabinet-b2b)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
cabinet-b2b.approval_required_above | int | 0 | нет | Сумма заказа, требующая согласования (0 — всегда) |
cabinet-b2b.max_employees_per_company | int | 50 | нет | Лимит сотрудников на компанию |
cabinet-b2b.invitation_ttl_hours | int | 72 | нет | Срок действия ссылки приглашения |
cabinet-b2b.max_pending_invitations_per_company | int | 20 | нет | Лимит одновременных неподтверждённых приглашений |
cabinet-b2b.require_dadata_verification | bool | false | нет | Kill-switch: блокировать регистрацию компании при недоступном suggest-provider (по умолчанию выключено — деградация в ручной ввод) |
Достижение max_employees_per_company/max_pending_invitations_per_company — понятная ошибка 422 и метрика, не 500 и не тихий игнор приглашения.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/cabinet/b2b/company | auth (владелец нового аккаунта) | Регистрация компании, инициатор становится администратором |
| GET | /api/v1/cabinet/b2b/company | auth (сотрудник компании) | Карточка компании |
| GET | /api/v1/cabinet/b2b/employees | auth (cabinet-b2b.manage.own) | Список сотрудников компании |
| POST | /api/v1/cabinet/b2b/employees/invite | auth (cabinet-b2b.manage.own) | Пригласить сотрудника по email |
| POST | /api/v1/cabinet/b2b/invitations/{token}/accept | auth | Принять приглашение |
| POST | /api/v1/cabinet/b2b/invitations/{token}/decline | auth | Отклонить приглашение |
| PUT | /api/v1/cabinet/b2b/employees/{id} | auth (cabinet-b2b.manage.own) | Изменить роль/лимит сотрудника |
| DELETE | /api/v1/cabinet/b2b/employees/{id} | auth (cabinet-b2b.manage.own) | Деактивировать сотрудника |
| POST | /api/v1/cabinet/b2b/employees/{id}/transfer-admin | auth (текущий администратор) | Передать роль администратора |
| POST | /api/v1/cabinet/b2b/orders/{id}/approve | auth (согласующий) | Одобрить заказ сверх лимита |
| POST | /api/v1/cabinet/b2b/orders/{id}/reject | auth (согласующий) | Отклонить заказ сверх лимита |
Список сотрудников и очередь согласований — keyset-пагинация (не OFFSET).
Компоненты
Filament: карточка компании с сотрудниками и лимитами, очередь согласований заказов, статус блокировки компании с причиной. Команды: cms:cabinet-b2b:doctor --json.
Демо-контент: сидер демо-компании с 3–5 сотрудниками разных ролей и парой заказов в очереди согласования — галерея /_gallery и playground показывают workflow без ручного ввода. Фронтенд-бюджет: очередь согласований и карточка компании — отдельный чанк React-острова кабинета, список сотрудников виртуализирован при больших компаниях, формы приглашения доступны с клавиатуры.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
B2bOrderApprovalRequested | заказ превысил лимит сотрудника | order_id, company_id, amount |
B2bOrderApproved | согласующий одобрил заказ | order_id, approved_by |
B2bOrderRejected | согласующий отклонил заказ | order_id, rejected_by, reason |
B2bEmployeeInvited | отправлено приглашение | company_id, email, role, invited_by |
B2bEmployeeInvitationAccepted | приглашение принято | company_id, user_id, role |
B2bEmployeeInvitationDeclined | приглашение отклонено | company_id, email |
B2bEmployeeDeactivated | сотрудник деактивирован (уволен) | company_id, user_id, deactivated_by |
B2bAdminRoleTransferred | роль администратора передана другому сотруднику | company_id, from_user_id, to_user_id |
B2bCompanyBlocked | компания заблокирована | company_id, reason |
B2bCompanyUnblocked | блокировка снята | company_id |
Слушает: UserMerged(primary_user_id, secondary_user_id) (ядро, канал 1, ревизия 14.07.2026, п. 13) — переносит запись сотрудника с вторичного аккаунта на первичный (см. «Крайние случаи»). Остального модуль сам не подписывается на чужие события — счета/акты и лимиты запрашиваются сервис-вызовом у cms/commerce-invoices и cms/commerce-b2b по suggests, реквизиты — у cms/dadata через suggest-provider.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/cabinet-b2c | сервис-вызов (requires) | cabinet-b2b → cms/cabinet-b2c | наследует аутентификацию, профиль и сессии сотрудника (компания — надстройка, не дублирует эти данные) |
cms/cabinet-b2c | provides-контракт cabinet-shell (CabinetSectionContract) | cabinet-b2b → cms/cabinet-b2c | регистрирует секции «компания», «сотрудники», «согласования» в общем UI кабинета |
Ядро: UserMerged | событие (канал 1) | ядро → cabinet-b2b | слияние дублей аккаунтов: запись cms_b2b_employees вторичного пользователя переносится на первичного (см. «Крайние случаи») |
cms/commerce-orders | сервис-вызов (заказы вызывают requires на cabinet-b2b) | cms/commerce-orders → cabinet-b2b | при оформлении заказа сверх spending_limit заказ переводится в ожидание согласования, издаётся B2bOrderApprovalRequested |
cms/commerce-invoices | сервис-вызов (suggests) | cabinet-b2b → cms/commerce-invoices | получение счетов/актов для выгрузки бухгалтерии; при выключении — раздел документов скрыт |
cms/commerce-b2b | сервис-вызов (suggests) | cabinet-b2b → cms/commerce-b2b | проверка договорных лимитов/персональных цен при оформлении заказа |
cms/dadata | provides-контракт suggest-provider | cabinet-b2b → cms/dadata | автозаполнение/валидация ИНН/КПП/адреса при регистрации компании |
NotificationDispatch (ядро) | сервисный контракт | cabinet-b2b → ядро | письмо-приглашение, уведомление о блокировке компании; log-fallback без notification-channel |
cms/audit (если включён) | событие | cabinet-b2b → cms/audit | деактивация сотрудника, блокировка компании, передача админ-роли — в журнал |
cms/attack-monitor (если включён) | событие | cabinet-b2b → cms/attack-monitor | серии неудачных попыток принять чужой токен приглашения — сигнал перебора |
Фоновая работа
Очередь cabinet-b2b (низкий приоритет), джобы идемпотентны, расписание через ScheduleRegistrar:
ExpirePendingInvitations— ежедневно, переводит приглашения с истёкшимexpires_atи статусомpendingвexpired;PurgeStaleInvitations— еженедельно, удаляетexpired/declined/revokedприглашения старше 90 дней (ПДн-ретеншн);EscalateStaleApprovals(при подключённомcms/commerce-orders) — переназначает или эскалирует администратору заявки на согласование, чей исходный согласующий деактивирован.
Приглашение/деактивация/согласование синхронны в рамках HTTP-запроса; выгрузка для бухгалтерии формируется по запросу (без очереди, при разумных объёмах — партиями с keyset-выборкой при больших периодах).
Эксплуатация (ранбук). Метрики: число активных приглашений, доля принятых/отклонённых/истёкших приглашений, длительность согласования заказа (от B2bOrderApprovalRequested до Approved/Rejected), число компаний в статусе blocked. Алерты: заявка на согласование висит дольше настраиваемого порога без реакции согласующего, доля истёкших приглашений аномально высокая (подсказка — TTL слишком короткий или письма не доходят).
| Симптом | Что проверить | Команда |
|---|---|---|
| Сотрудник не может принять приглашение | истёк expires_at, статус уже не pending, email не совпадает | cms:cabinet-b2b:doctor --json |
| Заказы зависают без согласования | у компании нет активного согласующего либо все деактивированы | cms:cabinet-b2b:doctor --json |
| Реквизиты по ИНН не подтягиваются | доступность cms/dadata / suggest-provider | cms:dadata:doctor --json |
| Сотрудник видит 403 при работе, хотя компания активна | статус компании (blocked/suspended), кеш карточки компании не инвалидирован | cms:cabinet-b2b:doctor --json, проверка тега cabinet-b2b:company:<id> |
Бэкап/рестор: в бэкап попадают все таблицы модуля. После рестора cms_b2b_invitations с истёкшим expires_at можно не восстанавливать (сотрудник пригласит заново); limit_snapshot в cms_b2b_order_approvals восстанавливается как есть — пересчёту не подлежит (исторический факт согласования).
Производительность и кеш
Ожидаемые объёмы: единицы-десятки тысяч компаний на инсталляцию, до max_employees_per_company (дефолт 50) сотрудников на компанию — таблицы небольшие, партиционирование не требуется. Горячий путь — открытие раздела компании и очереди согласований на каждой странице B2B-кабинета: бюджет ≤2 запроса (карточка компании + роль текущего сотрудника из кеша). Настройки — 0 запросов на горячем пути (кеш группы).
Критичные индексы — см. «Модель данных»: составной (company_id, user_id) в cms_b2b_employees (и защита от дублей, и быстрая проверка принадлежности — тот же индекс закрывает IDOR-проверку и производительность), частичный (company_id, email) в cms_b2b_invitations под идемпотентность повторного приглашения.
Теги cabinet-b2b:company:<company_id> — инвалидируются событиями B2bEmployeeInvitationAccepted/B2bEmployeeDeactivated/B2bOrderApproved/ B2bCompanyBlocked/B2bCompanyUnblocked. Данные компании и сотрудников персональны, в общий page-cache не попадают — только приватный кеш по company_id, не участвует в page-cache сайта.
Безопасность
IDOR — главный вектор. Каждый эндпоинт обязан скопировать проверку через принадлежность компании: WHERE company_id = $currentEmployee->company_id, не голый Order::find($id)/Company::find($id). Сотрудник видит заказы, счета и сотрудников только своей компании; попытка обратиться к чужому company_id в пути/теле запроса — 404, не 403 (не подтверждать существование чужой компании).
Роль «наблюдатель» не может инициировать заказ или согласование (только просмотр) — проверка через Policies, не «по строке роли». Права cabinet-b2b.manage.own не дают доступа к чужой компании.
Компания заблокирована посреди активной сессии сотрудника. Middleware проверяет статус компании на каждом запросе к /api/v1/cabinet/b2b/* (не только на входе): заблокированная компания отдаёт 403 с понятным сообщением, не 500; уже открытая вкладка клиента корректно обрабатывает следующий запрос, не рвётся аварийно. Разблокировка — только studio-ролью с подтверждением, событие в аудит.
Деактивация последнего администратора компании без назначения нового — запрещена (422): система требует сначала передать роль администратора другому активному сотруднику (POST .../transfer-admin), затем деактивировать. Деактивированный сотрудник теряет доступ немедленно (сессии инвалидируются через cms/cabinet-b2c), его незавершённые согласования переназначаются (см. «Крайние случаи»).
Матрица ролей:
| Действие | Администратор | Закупщик | Согласующий | Бухгалтер | Наблюдатель | studio |
|---|---|---|---|---|---|---|
| Просмотр карточки компании | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Оформление заказа от юрлица | ✅ | ✅ | — | — | — | — |
| Согласование/отклонение заказа сверх лимита | ✅ | — | ✅ | — | — | — |
| Приглашение/деактивация сотрудника, передача роли администратора | ✅ | — | — | — | — | ✅ (поддержка) |
Изменение spending_limit/роли сотрудника | ✅ | — | — | — | — | ✅ |
| Просмотр счетов/актов, выгрузка для бухгалтерии | ✅ | — | — | ✅ | — | — |
| Разблокировка компании после устранения причины | — | — | — | — | — | ✅ |
Права: cabinet-b2b.view.own, cabinet-b2b.manage.own, cabinet-b2b.approve.
UX-требования
Для сотрудника компании: очередь согласований — мобильно-адаптивная (согласующие часто одобряют с телефона), быстрое одобрение/отклонение с обязательной причиной при отклонении; форма приглашения при ошибке (сотрудник уже приглашён/уже в компании) показывает понятное сообщение, а не generic «422»; форма регистрации компании при сбое suggest-provider не блокируется — переключается на ручной ввод с пометкой «не удалось проверить автоматически».
Для админа/сотрудника студии: список сотрудников компании — пустое состояние «пригласите первого сотрудника» сразу после регистрации; массовая деактивация нескольких сотрудников (например, при закрытии отдела) — с превью, кто теряет доступ, и подтверждением; попытка деактивировать последнего администратора — предупреждение «сначала назначьте другого администратора», а не молчаливый отказ; разблокировка компании — явное подтверждение с указанием причины блокировки в интерфейсе.
Крайние случаи и типовые баги
- Повторное приглашение уже приглашённого email → обновляет существующую
pendingзаявку (новыйrole/expires_at), не создаёт дубль (частичный уникальный индекс(company_id, email)гдеstatus = pending). - Токен приглашения истёк → принятие отдаёт понятную ошибку «ссылка устарела», администратор видит кнопку повторной отправки в списке сотрудников.
- Деактивация последнего администратора без передачи роли → операция отклоняется (422) с требованием сначала назначить другого администратора («Крайние случаи» ↔ «Безопасность»).
- Компания заблокирована посреди активной сессии сотрудника → ближайший запрос к
/api/v1/cabinet/b2b/*отдаёт 403 «компания заблокирована», не 500; открытая вкладка не рвётся, кабинет показывает экран блокировки вместо белого экрана. cms/dadataнедоступен при регистрации компании по ИНН → реквизиты вводятся вручную без автозаполнения, регистрация не блокируется (deградация согласована сdocs/dadata.md); приrequire_dadata_verification = true(kill-switch наоборот требует проверку) — регистрация ставится в очередь на ручную верификацию studio, не отклоняется молча.- Сотрудник деактивирован (уволился), у него есть незавершённые согласования — его
pending-заявки не зависают: джобаEscalateStaleApprovalsпереназначает их другому активному согласующему компании или эскалирует администратору. - Гонка двух согласующих на одном заказе (двойной клик/два окна) → первый
approveфиксирует статус (optimistic lock поstatus), второй параллельный запрос получает 409 «уже согласовано», не создаёт второе решение. spending_limitизменён администратором после создания заявки на согласование → согласование продолжается поlimit_snapshot, зафиксированному на момент создания заявки, не пересчитывается задним числом.- Лимит
max_employees_per_companyдостигнут → новое приглашение отклоняется понятной ошибкой 422, не создаётся и не игнорируется тихо. - Приглашённый отклоняет приглашение → статус
declined, email компании освобождается для повторного приглашения без ручной очистки записи. - ⚠️ Противоречие:
approval_required_above = 0(все заказы требуют согласования) при отсутствии в компании ни одного активного сотрудника с ролью «согласующий» → заказы зависают без возможности оформления, workflow тупиковый. Разрешение:cms:cabinet-b2b:doctorприapproval_required_above = 0проверяет наличие хотя бы одного активного согласующего и алертит явной ошибкой в админке компании; до назначения согласующего администратор компании временно считается согласующим по умолчанию (не тихий тупик). - Свежезарегистрированная компания без сотрудников кроме администратора → пустые состояния в кабинете и Filament с подсказкой «пригласите сотрудников», не пустой список без объяснения.
- Слияние аккаунтов (
UserMerged) сотрудника компании → обработчик переноситcms_b2b_employees.user_idсо вторичного аккаунта на первичный; если первичный пользователь уже был сотрудником той же компании (коллизия уникального индекса(company_id, user_id)) — запись вторичного помечаетсяdeactivatedи не дублируется, роль/лимит остаются от более привилегированной из двух записей (администратор > закупщик/согласующий/бухгалтер > наблюдатель), событиеB2bEmployeeDeactivatedиздаётся с причинойmerged; если вторичный пользователь не был сотрудником — переносuser_idбез изменения роли/лимита.
Донорский код
Донор: — (новая разработка).
Миграция legacy: для клиентов, переезжающих с самописных B2B-личных кабинетов — команда cms:cabinet-b2b:import-legacy --source=<профиль> маппит старые таблицы компаний/сотрудников на cms_b2b_companies/cms_b2b_employees по ключу external_id; идемпотентна (повторный прогон обновляет, не дублирует), --dry-run с отчётом расхождений. Прогоняется на копии донорских данных как часть приёмки, если у клиента есть боевой B2B-кабинет на старой платформе.
Тесты и приёмка
- [ ] Контрактный тест: заказ сотрудника сверх
spending_limitне оформляется без одобрения согласующего;limit_snapshotфиксируется на момент заявки; - [ ] IDOR-тест: сотрудник не видит заказы/счета/сотрудников чужой компании при переборе
company_id/id(изоляция черезWHERE company_id = ..., ответ 404); - [ ] Роль «наблюдатель» не может инициировать заказ или согласование (только просмотр);
- [ ] Приглашение: повторное приглашение того же email обновляет заявку, не дублирует; истёкший токен отдаёт понятную ошибку; принятие/отклонение меняют статус корректно;
- [ ] Деактивация последнего администратора без передачи роли отклоняется (422); деактивированный сотрудник немедленно теряет доступ (инвалидация сессий);
- [ ] Незавершённые согласования деактивированного сотрудника переназначаются, не зависают (
EscalateStaleApprovals); - [ ] Блокировка компании прерывает доступ на ближайшем запросе (403, не 500) для уже открытой сессии сотрудника;
- [ ] Гонка двух согласующих на одном заказе — второй запрос получает 409, не создаёт второе решение;
- [ ] Деградация при выключении переводит кабинет в B2C-режим без потери данных компании; деградация при недоступном
cms/dadataне блокирует регистрацию; - [ ] Лимиты
max_employees_per_company/max_pending_invitations_per_companyпроверяются и отдают понятную ошибку при достижении; - [ ] Права
cabinet-b2b.manage.ownне дают доступа к чужой компании (проверка принадлежности на каждом мутирующем эндпоинте); - [ ] Хук «забыть по запросу» для email неподтверждённых приглашений покрыт тестом (ПДн-паспорт);
- [ ]
UserMergedпереносит запись сотрудника на первичный аккаунт без дублей при коллизии уникального индекса(company_id, user_id); - [ ] Секции кабинета регистрируются через
cabinet-shell(CabinetSectionContract), не отдельным layout; - [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
cabinet-b2b_test;migrate:fresh/refresh/reset,db:wipeзапрещены.