Skip to content

ТЗ — Продажа услуг (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_variantsid, variant_id, service_id (nullable)связь ТП-услуги с карточкой cms/services
commerce_service_fulfilmentsid, 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_logorder_item_id, from_status, to_status, actor_id, created_atappend-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-payments1 (событие)payment_id, order_id, amount; commerce-services фильтрует позиции заказа с fulfilment: service, остальное игнорирует
Назначение исполнителяадмин-форма / POST /assignAPIassignee_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.enabledbooltrueнетВключение покупаемых услуг в каталоге. Kill-switch модуля: при инциденте (например, сбой синхронизации с cms/services начал плодить дубли вариантов) отключается быстро, без деинсталляции модуля — продажа услуг блокируется, уже оплаченные заказы продолжают исполняться и видны в Filament
commerce-services.require_assigneeboolfalseнетОбязательность назначения исполнителя перед «выполнена»
commerce-services.subject_type_codestringserviceнетКод признака предмета расчёта для чеков
commerce-services.cancellation_deadline_hoursint24нетМинимальный срок до начала услуги, после которого отмена/перенос заблокированы без ручного вмешательства администратора
commerce-services.late_cancellation_fee_percentint0нетШтраф при отмене позже дедлайна (0 = без штрафа), удерживается из возврата при сторно чека
commerce-services.stale_unassigned_alert_hoursint24нетПорог для алерта «оплаченная услуга без исполнителя» (см. «Фоновая работа»)

Лимиты и квоты. Максимум одновременных назначений на одного исполнителя — не применимо: модуль не отвечает за загрузку рабочего времени исполнителя (это ёмкость слотов cms/calendars, когда услуга требует запись на время); менеджер видит нагрузку глазами по списку и назначает осознанно. Если практика студии потребует жёсткого лимита — вводится отдельной настройкой в этом же файле, не сейчас.

API

МетодПутьДоступНазначение
GET/api/v1/orders/{id}/service-itemsauth (владелец заказа)Статусы исполнения услуг в заказе
POST/api/v1/admin/service-fulfilments/{id}/assignadmin (commerce-services.manage)Назначение исполнителя
POST/api/v1/admin/service-fulfilments/{id}/completeadmin (commerce-services.manage) либо исполнитель (commerce-services.fulfil-own и assignee_id = auth.id)Отметка «выполнена»
GET/api/v1/admin/service-fulfilments/mineadmin (commerce-services.fulfil-own)Список назначений текущего исполнителя, keyset-пагинация

Конфликт lock_version на assign/complete409 с 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-payments1 (событие)payments → commerce-servicesPaymentSucceeded идемпотентно переводит 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/services4 (requires)commerce-services → cms/servicessync-catalog синхронизирует покупаемые варианты с карточками услуг
cms/calendars4/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 запрещены

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