Skip to content

ТЗ — Клиентский кабинет 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_companiesid, name, inn, kpp, legal_address, credit_limit, status, blocked_reason, blocked_atкарточка организации; status — PHP Enum (active/blocked/suspended)
cms_b2b_employeesid, company_id, user_id, role, status, spending_limit, invited_byсотрудник компании с ролью, лимитом и статусом (active/deactivated)
cms_b2b_invitationsid, company_id, email, role, token_hash, invited_by, expires_at, statusприглашение сотрудника; status — PHP Enum (pending/accepted/declined/expired/revoked)
cms_b2b_order_approvalsid, order_id, company_id, requested_by, approved_by, status, limit_snapshotсогласование заказа сверх лимита; limit_snapshot — лимит на момент заявки

FK company_id, user_id, order_idconstrained()->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_addressFormRequest: формат ИНН/КПП (контрольная сумма), автозаполнение/сверка через 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_aboveint0нетСумма заказа, требующая согласования (0 — всегда)
cabinet-b2b.max_employees_per_companyint50нетЛимит сотрудников на компанию
cabinet-b2b.invitation_ttl_hoursint72нетСрок действия ссылки приглашения
cabinet-b2b.max_pending_invitations_per_companyint20нетЛимит одновременных неподтверждённых приглашений
cabinet-b2b.require_dadata_verificationboolfalseнетKill-switch: блокировать регистрацию компании при недоступном suggest-provider (по умолчанию выключено — деградация в ручной ввод)

Достижение max_employees_per_company/max_pending_invitations_per_company — понятная ошибка 422 и метрика, не 500 и не тихий игнор приглашения.

API

МетодПутьДоступНазначение
POST/api/v1/cabinet/b2b/companyauth (владелец нового аккаунта)Регистрация компании, инициатор становится администратором
GET/api/v1/cabinet/b2b/companyauth (сотрудник компании)Карточка компании
GET/api/v1/cabinet/b2b/employeesauth (cabinet-b2b.manage.own)Список сотрудников компании
POST/api/v1/cabinet/b2b/employees/inviteauth (cabinet-b2b.manage.own)Пригласить сотрудника по email
POST/api/v1/cabinet/b2b/invitations/{token}/acceptauthПринять приглашение
POST/api/v1/cabinet/b2b/invitations/{token}/declineauthОтклонить приглашение
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-adminauth (текущий администратор)Передать роль администратора
POST/api/v1/cabinet/b2b/orders/{id}/approveauth (согласующий)Одобрить заказ сверх лимита
POST/api/v1/cabinet/b2b/orders/{id}/rejectauth (согласующий)Отклонить заказ сверх лимита

Список сотрудников и очередь согласований — 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-b2cprovides-контракт 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/dadataprovides-контракт suggest-providercabinet-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-providercms: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 запрещены.

Внутренняя база знаний студии