Тема
ТЗ — Продажа услуг (fulfilment) (cms/commerce-services)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Услуга как покупаемая позиция заказа: без остатков и доставки, со своим признаком предмета расчёта в чеке и статусом исполнения вместо статуса отгрузки. Мост с контентным модулем cms/services превращает карточку услуги в покупаемый вариант. Модель — см. /cms-v2/commerce-model, раздел «Продажа услуг».
Модуль отвечает только за fulfilment (исполнение уже купленной и оплаченной услуги): заказ услуги, назначение исполнителя, отметка о выполнении. Запись на конкретное время (слот, TTL-резерв времени, отмена/перенос по срокам) — зона cms/calendars; разграничение и мост между модулями — в разделах «Зависимости и выключение» и «Крайние случаи» ниже.
- ТП с признаком
fulfilment: service— без складского учёта и доставки - Собственный признак предмета расчёта «услуга» в чеке (
cms/commerce-receipts) - Статус исполнения услуги: заказана → назначена исполнителю → выполнена
- Назначение исполнителя на позицию заказа (сотрудник/подрядчик)
- Смешение товаров и услуг в одном заказе (например монтаж + материалы)
- Мост с
cms/services: карточка услуги контентной иерархии получает покупаемый вариант
Зависимости и выключение
requires: ядро, cms/commerce-model (заказы) · suggests: cms/services, cms/calendars (запись на время — не зона этого модуля).
cms/calendars — формально suggests (модуль работает и без него: услуги без записи на конкретное время просто не показывают слот), но для конкретной услуги, у которой в cms/services включена запись на время, связь фактически жёсткая: без cms/calendars покупатель не может выбрать и удержать слот, и такая услуга ведёт себя как временно недоступная для оформления (деградация конкретной карточки, не всего модуля). Прямой вызов идёт через requires-контур цепочки cms/services → cms/calendars, commerce-services в этот вызов не встаёт — он только слушает исход (см. «Взаимодействия»).
Поведение при выключении: позиции с fulfilment: service не могут быть добавлены в корзину — карточки контентного модуля cms/services продолжают работать как лид-витрина без кнопки «оплатить онлайн». Существующие оплаченные fulfilment-записи не удаляются и остаются доступными на чтение владельцу заказа и администратору.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_service_variants | id, variant_id, service_id (nullable) | связь ТП-услуги с карточкой cms/services |
commerce_service_fulfilments | id, order_item_id, status (ordered|assigned|completed), assignee_id (nullable), completed_at, lock_version | статус исполнения, status — PHP Enum; lock_version — optimistic lock для параллельного редактирования (стандарт §4) |
commerce_service_fulfilment_log | order_item_id, from_status, to_status, actor_id, created_at | append-only журнал переходов, BRIN по created_at |
variant_id/service_id/order_item_id/assignee_id — FK constrained()->index(); индекс на status в commerce_service_fulfilments — под выборку «в работе» (см. «Производительность и кеш»).
ПДн-паспорт. Модуль не хранит ФИО/контакты — только внешние id (assignee_id, actor_id) на пользователей ядра. Сама связь «кто из исполнителей обслуживал чей заказ» — умеренно чувствительные данные (рабочая биография сотрудника/подрядчика). Ретеншн журнала commerce_service_fulfilment_log — конфигурируемый (см. «Настройки»), по истечении — actor_id/assignee_id заменяются на обезличенный системный id при «забыть по запросу», факт перехода статуса (нужен для отчётности и разбора инцидентов) не удаляется. «Выгрузить всё по субъекту»: отдаёт список fulfilment-записей, где пользователь фигурирует как assignee_id (роль исполнителя); как покупатель субъект фигурирует через заказ и здесь не хранится напрямую.
Входные и выходные данные
Входы (всё, что не перечислено ниже — отвергается, whitelist-принцип §11 стандарта):
| Вход | Источник | Канал | Поля/валидация |
|---|---|---|---|
PaymentSucceeded | событие cms/commerce-payments | 1 (событие) | payment_id, order_id, amount; commerce-services фильтрует позиции заказа с fulfilment: service, остальное игнорирует |
| Назначение исполнителя | админ-форма / POST /assign | API | assignee_id — FormRequest, whitelist: только активные пользователи с ролью, допускающей назначение (см. «Безопасность») |
| Отметка «выполнена» | админ-форма / POST /complete, либо сам исполнитель | API | без обязательных полей; опциональный completed_at (по умолчанию — время запроса), не может быть в будущем |
Выходы:
| Выход | Получатель | Формат |
|---|---|---|
| Статус исполнения услуги | покупатель, GET /orders/{id}/service-items | конверт {data:[{order_item_id, status, assignee_visible_name?, completed_at}], meta} |
ServiceFulfilmentAssigned / ServiceFulfilmentCompleted | шина событий (канал 1) | см. «События и обмен» |
| Признак предмета расчёта | commerce_orders-позиция → cms/commerce-receipts | значение commerce-services.subject_type_code проставляется на позицию заказа при создании fulfilment-записи (сервис-вызов requires в commerce-model), чек читает его при формировании — без обращения к commerce-services напрямую |
Настройки (группа commerce-services)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-services.enabled | bool | true | нет | Включение покупаемых услуг в каталоге. Kill-switch модуля: при инциденте (например, сбой синхронизации с cms/services начал плодить дубли вариантов) отключается быстро, без деинсталляции модуля — продажа услуг блокируется, уже оплаченные заказы продолжают исполняться и видны в Filament |
commerce-services.require_assignee | bool | false | нет | Обязательность назначения исполнителя перед «выполнена» |
commerce-services.subject_type_code | string | service | нет | Код признака предмета расчёта для чеков |
commerce-services.cancellation_deadline_hours | int | 24 | нет | Минимальный срок до начала услуги, после которого отмена/перенос заблокированы без ручного вмешательства администратора |
commerce-services.late_cancellation_fee_percent | int | 0 | нет | Штраф при отмене позже дедлайна (0 = без штрафа), удерживается из возврата при сторно чека |
commerce-services.stale_unassigned_alert_hours | int | 24 | нет | Порог для алерта «оплаченная услуга без исполнителя» (см. «Фоновая работа») |
Лимиты и квоты. Максимум одновременных назначений на одного исполнителя — не применимо: модуль не отвечает за загрузку рабочего времени исполнителя (это ёмкость слотов cms/calendars, когда услуга требует запись на время); менеджер видит нагрузку глазами по списку и назначает осознанно. Если практика студии потребует жёсткого лимита — вводится отдельной настройкой в этом же файле, не сейчас.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/orders/{id}/service-items | auth (владелец заказа) | Статусы исполнения услуг в заказе |
| POST | /api/v1/admin/service-fulfilments/{id}/assign | admin (commerce-services.manage) | Назначение исполнителя |
| POST | /api/v1/admin/service-fulfilments/{id}/complete | admin (commerce-services.manage) либо исполнитель (commerce-services.fulfil-own и assignee_id = auth.id) | Отметка «выполнена» |
| GET | /api/v1/admin/service-fulfilments/mine | admin (commerce-services.fulfil-own) | Список назначений текущего исполнителя, keyset-пагинация |
Конфликт lock_version на assign/complete — 409 с code: version_conflict (конверт ошибок ядра, ревизия 14.07.2026).
Компоненты
Filament: список позиций-услуг с фильтром по статусу исполнения, назначение исполнителя, массовое назначение (bulk action на выбранных ordered-позициях одному исполнителю). Команды: cms:commerce-services:sync-catalog --json (синхронизация покупаемых вариантов с карточками cms/services).
Демо-контент: сидер демо-услуг (несколько позиций в статусах ordered/assigned/ completed с фиктивными исполнителями) — для галереи блоков cms/services и playground, без ручного оформления тестового заказа.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ServiceFulfilmentAssigned | услуга назначена исполнителю | order_item_id, assignee_id |
ServiceFulfilmentCompleted | услуга отмечена выполненной | order_item_id, completed_at |
Слушает: PaymentSucceeded из cms/commerce-payments (перевод позиции услуги в статус ordered). Мост к cms/services — прямой сервис-вызов по requires для синхронизации покупаемого варианта с карточкой контентной иерархии.
Взаимодействия:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-payments | 1 (событие) | payments → commerce-services | PaymentSucceeded идемпотентно переводит fulfilment-записи заказа в ordered |
cms/commerce-model (order_item) | 4 (requires) | commerce-services → commerce-model | При создании fulfilment-записи проставляется subject_type_code на позицию заказа |
cms/commerce-receipts | — (косвенно) | commerce-model (order_item) → commerce-receipts | Чек читает признак предмета расчёта с позиции заказа при формировании; commerce-services не вызывается напрямую |
cms/services | 4 (requires) | commerce-services → cms/services | sync-catalog синхронизирует покупаемые варианты с карточками услуг |
cms/calendars | 4/1 (фактически requires при записи на время; событийно, suggests) | cms/services → cms/calendars (резерв/подтверждение слота) | commerce-services не участвует в резерве слота напрямую — реагирует только на факт оплаты/статус позиции, слот и его TTL — целиком в cms/calendars (см. «Крайние случаи») |
Фоновая работа
Именованная очередь commerce-services: cms:commerce-services:sync-catalog по расписанию (синхронизация вариантов с cms/services). Переход в ordered по PaymentSucceeded — обработка события тонким слушателем, кладущим job без внешних вызовов (данные внутри платформы).
Эксплуатация (ранбук). Метрика commerce_services_unassigned_hours — доля/список оплаченных услуг без исполнителя дольше stale_unassigned_alert_hours; алерт в cms/health, не молчаливое накопление.
| Симптом | Что проверить | Чем чинить |
|---|---|---|
| Растёт очередь «без исполнителя» | Filament-фильтр status=ordered, assignee_id=null | Массовое назначение (bulk action) |
sync-catalog не подтягивает новые услуги | очередь commerce-services, cms:commerce-services:sync-catalog --dry-run --json | Ручной прогон без --dry-run после фикса причины |
Дубли ServiceFulfilmentCompleted в логах подписчиков | журнал commerce_service_fulfilment_log на предмет двух подряд одинаковых переходов | Не требуется — переход идемпотентен по текущему статусу, повторная доставка события молча игнорируется |
| Расхождение слота и статуса исполнения | сверка CalendarConflictDetected в cms/calendars с commerce_service_fulfilments.status | Ручное решение администратора (см. «Крайние случаи») |
Бэкап/рестор. В бэкап — все три таблицы модуля целиком (append-only журнал включая). Денормализованных агрегатов и поисковых индексов у модуля нет — после рестора БД дополнительная восстановительная команда не требуется.
Производительность и кеш
Объёмы — умеренные: количество fulfilment-записей растёт вместе с числом оплаченных заказов с услугами, не с каталогом. Горячий путь — список услуг в конкретном заказе (GET /orders/{id}/service-items), обычно 1–3 позиции, редко больше десятка; запрос идёт по индексу order_item_id, без постраничности. Индекс на status в commerce_service_fulfilments — под админ-выборку «в работе»/«без исполнителя» (keyset, ≤15 запросов на список — бюджет ядра). Каталог покупаемых услуг участвует в общем кеше commerce-catalog (не дублирует собственный публичный кеш карточки).
Теги commerce-services:fulfilment:<order_item_id>. Инвалидация — событиями ServiceFulfilmentAssigned/Completed, не ручным flush. Статус исполнения — персональные данные заказа: страница заказа покупателя не попадает в общий page-cache (сегментация по правилам ядра §10 стандарта), только сегментный/приватный кеш ответа API.
Безопасность
Статусы исполнения — персональные данные заказа, видны только владельцу (auth) и администратору с правом commerce-services.view. Назначение исполнителя и отметка «выполнена» — под commerce-services.manage (для исполнителя, отмечающего свою же запись, — отдельное узкое право commerce-services.fulfil-own), фиксируются в append-only журнале с actor_id. Права: commerce-services.view, commerce-services.manage, commerce-services.fulfil-own.
Матрица ролей:
| Роль | Свой заказ (статус) | Список/фильтр по статусу | Назначение исполнителя | «Выполнена» (чужая запись) | «Выполнена» (своя запись) |
|---|---|---|---|---|---|
| Покупатель | ✅ | — | — | — | — |
Исполнитель (fulfil-own) | — | только свои назначения | — | — | ✅ |
Менеджер (view+manage) | — | ✅ | ✅ | ✅ | ✅ |
| Админ/studio | ✅ (любой заказ) | ✅ | ✅ | ✅ | ✅ |
Переход статуса — только по разрешённым дугам (ordered → assigned → completed), попытка иного перехода — 422 с человекочитаемой ошибкой, не тихий no-op и не 500. Параллельное редактирование одной записи двумя менеджерами — lock_version (409 + понятное сообщение, не «последний победил»).
UX-требования
Покупатель: статус услуги в заказе — человекочитаемая формулировка («Ожидает назначения» / «Исполнитель назначен» / «Услуга выполнена»), не сырой код enum; уведомление о назначении исполнителя и о выполнении услуги — через notification-channel (канал письма/мессенджера выбирает ядро, не этот модуль); если услуге требуется запись на время, а слот ещё не подтверждён cms/calendars — отдельная плашка «время уточняется».
Админ: пустое состояние списка «услуги без исполнителя» — не «нет данных», а счётчик и подсказка с прямой ссылкой на массовое назначение; массовое назначение — выбор нескольких позиций → один исполнитель одним действием, с проверкой, что все выбранные позиции в статусе ordered (иначе — предупреждение, какие позиции пропущены и почему); кнопка «выполнена» у записи без исполнителя при require_assignee=true — задизейблена с тултипом причины, а не активна с ошибкой после клика; конфликт lock_version — «запись изменена другим пользователем, обновите страницу», без потери введённых данных формы.
Крайние случаи и типовые баги
- Слот занят между выбором времени и оплатой заказа. ⚠️ Противоречие: этот раздел предполагает, что TTL-резерв слота держит
cms/calendars, но действующее ТЗcms/calendarsописывает только внешнюю CalDAV-синхронизацию (Google/Яндекс), без TTL-резерва и штрафов за отмену;commerce-model.mdназывает бронирование слота «отдельным модулем поверх», которого в парке модулей пока нет. Разрешение до появления такого модуля/ревизииcms/calendars: слот резервируется на сторонеcms/services/cms/calendarsпри выборе времени сTTL; если TTL истекает до оплаты — резерв снимается автоматически, checkout отклоняет оплату по этой позиции кодомslot_expired, покупателю предлагается выбрать время заново. commerce-services в момент истечения TTL ничего не делает — она видит только итог (оплачен заказ со слотом или без него). - Отмена/перенос записи на услугу. Разрешены не позже
cancellation_deadline_hoursдо начала; позже — блокируется для покупателя, доступно администратору вручную;late_cancellation_fee_percent > 0удерживает штраф из возврата (частичное сторно чека вcms/commerce-receipts). - Рассинхрон слота и статуса исполнения. Источник правды по занятости времени —
cms/calendars, источник правды по статусу исполнения —commerce_service_fulfilmentsэтого модуля; расхождение (например слот отменён во внешнем календаре, а fulfilment всё ещёassigned) сигнализируетсяCalendarConflictDetected, commerce-services статус автоматически не меняет — решение за администратором (см. ранбук). - Исполнитель уволен/стал недоступен. Активные (
assigned, неcompleted) записи переназначаются массово на другого исполнителя (Filament bulk action), покупателю уходит уведомление о смене;completed-записи не трогаются — это уже свершившийся факт. - Оплаченная услуга без исполнителя долго висит в
ordered. Алерт поstale_unassigned_alert_hours(см. ранбук), не тихое накопление. - Двойная доставка
PaymentSucceeded. Переход вorderedидемпотентен: слушатель проверяет текущий статус перед записью, повторное событие — no-op. - Смешанный заказ (товар + услуга). Частичная отгрузка товарной части не блокирует исполнение услуги и наоборот — статусы независимы (fulfilment услуги живёт своим жизненным циклом, не привязан к статусу доставки заказа целиком).
- Отмена всего заказа при уже выполненной услуге. Fulfilment остаётся
completed(исторический факт оказанной услуги не переписывается задним числом); финансовая часть (полный/частичный возврат) регулируется сторно вcms/commerce-receipts, не откатом статуса исполнения. - Попытка недопустимого перехода статуса (например
completed → ordered) —422, человекочитаемая ошибка, запись не меняется.
Донорский код
Донор: — (новая разработка).
Миграция legacy. cms:commerce-services:import-legacy --source=<профиль> — маппинг экспорта донора (заказ/позиция/исполнитель/статус) на commerce_service_fulfilments, ключ идемпотентности — external_id; --dry-run отдаёт отчёт расхождений до применения.
Тесты и приёмка
- [ ] Контрактный тест: позиция с
fulfilment: serviceне создаёт записи вcommerce_stockи не требует метода доставки - [ ] Переход статуса исполнения возможен только по разрешённым дугам (
ordered → assigned → completed) - [ ]
require_assignee=trueблокирует переход вcompletedбез назначенного исполнителя - [ ] Заказ со смешанными позициями (товар + услуга) корректно проходит checkout с частичной доставкой только товарных позиций
- [ ] Чек содержит признак предмета расчёта «услуга» для соответствующих позиций
- [ ] При выключении модуля добавление услуги в корзину недоступно, существующие заказы с услугами не ломаются
- [ ] Двойная доставка
PaymentSucceededне создаёт дублей и не меняет уже продвинутый статус - [ ] Параллельное редактирование одной fulfilment-записи двумя менеджерами —
409поlock_version, не «последний победил» - [ ] Матрица ролей: исполнитель с
fulfil-ownвидит и завершает только свои назначения, не чужие - [ ] Отмена позже
cancellation_deadline_hoursблокируется для покупателя, доступна администратору со штрафом поlate_cancellation_fee_percent - [ ] «Забыть по запросу» обезличивает
assignee_id/actor_idв журнале, не удаляя сам факт перехода - [ ] TTL-резерв слота и его взаимодействие с
cms/calendars— не покрыто тестами этого модуля (логика в зонеcms/calendars/будущего модуля бронирования, см. «⚠️ Противоречие»); здесь тестируется только реакция на итоговый факт оплаты - [ ]
cms:commerce-services:import-legacy --dry-runне создаёт записей, повторный прогон без--dry-runидемпотентен поexternal_id - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API
- [ ] Тестовая БД — только
commerce-services_test;migrate:fresh/refresh/resetзапрещены