PUT /api/v1/agency/payout-orders/{orderId}/items — Правка состава поручения (только Draft)
Изменение состава платёжного поручения в статусе Draft: добавление свободных Agent-строк
распределения подтверждённых начислений в поручение и исключение ранее включённых
(INV-1/INV-5 domain/payout-order.md). Ручная корректировка автосборки,
выполненной при createPayoutOrder (create-payout-order.md): руководитель/
бухгалтер агентства убирает лишние выплаты или добавляет пропущенные до утверждения поручения.
Назначение
Собрать окончательный состав поручения перед утверждением: приложить/снять Agent-доли комиссий
(строки CommissionDistributionEntity подтверждённых начислений, domain/commission-accrual.md)
и пересчитать суммы позиций и итог. Операция допустима только в Draft — после Approve состав
заморожен (INV-5 ComposeOnlyInDraft). Feature group — payouts.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | rewards |
| API area | agency |
| Feature group | payouts |
| Статус | draft |
| Документ | docs/06-services/rewards/api/agency/update-payout-order-items.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (руководитель / бухгалтер агентства) |
| Auth policy / Permissions | rewards.payouts.manage (op23 реестра README.md) |
| Scope (RBAC) | организация: компания Type ∈ {Agency, Broker, Pin} (агентство-владелец поручения) |
| Record-level | order.TenantId == CompanyId актора (tenant-интерсептор); чужое поручение → 404 |
| Tenant isolation | Same-tenant: поручение, позиции и строки распределения — в tenant-е агентства (INV-9); загрузка ограничена tenant-ом агентства |
| Audit | update: UpdatedAt, UpdatedByUserId = ActorUserId, Version + 1; статус не меняется (остаётся Draft); финансовых событий нет |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/v1/agency/payout-orders/{orderId}/items |
| Success status | 200 |
| Idempotency header | не применяется (тело — дельта add/remove, не полный snapshot; защита — ExpectedVersion, повтор → REWARDS_CONCURRENCY_CONFLICT/REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims (tenant_id) | Guid | Да | Required | tenant isolation |
ActorUserId | identity claims (sub) | Guid | Да | Required | — |
OrderId | route {orderId} | Guid | Да | Required, != Guid.Empty | — |
ExpectedVersion | body | long | Да | >= 1; сверка с PayoutOrderEntity.Version | optimistic concurrency (конкурент — Approve/Cancel) |
AddDistributionIds | body | IReadOnlyList<Guid>? | Нет | уникальны, <= 500; каждый — Agent-строка свободного начисления в MoneyReceived | — |
RemoveDistributionIds | body | IReadOnlyList<Guid>? | Нет | уникальны, <= 500; каждый — строка, включённая в позицию этого поручения | — |
Инварианты валидатора: хотя бы один из списков непустой (иначе ValidationError); пересечение
AddDistributionIds ∩ RemoveDistributionIds пусто.
Модель ответа PayoutOrderModel
Полная проекция PayoutOrderEntity — дом README.md (PayoutOrderModel). Дельта операции:
после правки Items отдаётся полностью пересчитанным, а денормализации отражают новый состав.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Status | string (enum PayoutOrderStatus) | Да | Status | — | Всегда Draft (правка состава статус не меняет). |
TotalAmount | MoneyModel | Да | TotalAmount | Sensitive | Пересчитанный итог = Σ Items[].Amount (INV-3). |
ItemsCount | int | Да | ItemsCount | — | Пересчитанное число позиций. |
Items | IReadOnlyList<PayoutOrderItemModel> | Да | child-коллекция | Sensitive (Amount) | Позиции по агентам после add/remove; опустевшая позиция удаляется. |
Version | long | Да | Version | — | Новая версия — использовать как ExpectedVersion следующего вызова. |
Алгоритм
- Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании
Agency/Broker/Pin) — владелец поручения; scope — организация (TenantIdиз claims); требуется правоrewards.payouts.manage. - Проверка входа и предусловий.
- формат:
OrderIdнепустой,ExpectedVersion >= 1; суммарный наборAddDistributionIds ∪ RemoveDistributionIdsнепуст, элементы уникальны, списки не пересекаются, каждый список ≤ 500; иначе ошибка валидации; - существование: поручение с
OrderIdв tenant-е агентства существует; если нет (или чужой tenant) →REWARDS_PAYOUT_ORDER_NOT_FOUND; - допустимость состояния (INV-5): статус —
Draft; после утверждения состав заморожен →REWARDS_PAYOUT_COMPOSITION_LOCKED; - конкуренция: версия совпадает с
ExpectedVersion; иначеREWARDS_CONCURRENCY_CONFLICT. Поручение читается под блокировкой вместе с позициями.
- формат:
- Что читаем.
- добавляемые строки:
Agent-строки распределения поAddDistributionIds— годные только если принадлежат tenant-у агентства (INV-9),RecipientType = Agent, начисление вMoneyReceived(гейт получения денег пройден, ADR-0067), строка свободна (PayoutOrderItemId = null); захват под блокировкой с пропуском занятых. Если найдены не все (строка занята/неAgent/начисление неMoneyReceived/чужой tenant) →REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE; - исключаемые строки: строки по
RemoveDistributionIds, каждая обязана указывать на позицию этого поручения; снятие чужой/свободной строки →REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE.
- добавляемые строки:
- Пересборка состава.
- добавление — группировка добавляемых строк по агенту (INV-1): на агента без позиции создаётся
новая позиция (INV-2; уникальность
(поручение, агент)страхует гонку →REWARDS_PAYOUT_DUPLICATE_RECIPIENT), строка помечается ссылкой на позицию; валюта строки должна совпадать с валютой поручения (INV-4) →REWARDS_CURRENCY_MISMATCH; - исключение —
PayoutOrderItemIdстроки сбрасывается вnull(INV-8: строка снова свободна;PaidAtне трогаем —Draftникогда не былPaid); - пересчёт (INV-3) — для каждой затронутой позиции
Amount = Σпривязанных строк и число строк; опустевшая позиция удаляется;TotalAmount = Σ Items[].Amount,ItemsCount = Items.Count; при рассинхроне сумм →REWARDS_PAYOUT_SUM_MISMATCH. Статус остаётсяDraft, растёт версия.
- добавление — группировка добавляемых строк по агенту (INV-1): на агента без позиции создаётся
новая позиция (INV-2; уникальность
- Согласованность. Поручение, позиции и привязка строк распределения обновляются в одном согласованном
изменении. Гонка за ту же строку с параллельным поручением разрешается захватом под блокировкой с
пропуском занятых, гонка с
Approve/Cancel— сверкой версии поручения. Интеграционных/доменных событий правкаDraftне поднимает (события — только на переходахApprove/MarkPaid/Reject, domain/payout-order.md). - Результат. Полная проекция поручения с пересчитанными
Items(Status = Draft, новая версия) → HTTP200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (правка Draft — внутренняя) |
| External calls | none |
| Cache / projections / search | none |
| Notifications | none (уведомления агентам — при Approve/MarkPaid) |
Интеграционная последовательность. Правка состава синхронна и атомарна внутри rewards, без
Kafka-событий: пересчитанные Items/TotalAmount/ItemsCount возвращаются сразу в ответе. Освобождённые
строки (PayoutOrderItemId → null) немедленно доступны другим Draft-поручениям того же tenant; захват
добавляемых строк — с пропуском занятых, поэтому две параллельные правки не включат одну строку дважды
(проигравший видит REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE). Уведомления и интеграционные события
возникают только на последующих переходах Approve/MarkPaid/Reject; на правке Draft их нет — до
утверждения состав виден только агентству-владельцу.
Ошибки и failure modes
Общие коды (ValidationError, FORBIDDEN, UNAUTHORIZED, REWARDS_CONCURRENCY_CONFLICT) — README.md.
Дельта операции:
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
REWARDS_PAYOUT_ORDER_NOT_FOUND | 404 | Поручение не существует или чужой tenant | «Поручение не найдено» | no retry |
REWARDS_PAYOUT_COMPOSITION_LOCKED | 409 | Status != Draft (INV-5) — состав заморожен после утверждения | «Состав поручения нельзя изменить» | no retry |
REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE | 409 | Добавляемая строка занята/не Agent/начисление не MoneyReceived/чужой tenant, либо исключаемая строка не принадлежит поручению (INV-1) | «Строка распределения недоступна» | retry после refresh |
REWARDS_PAYOUT_DUPLICATE_RECIPIENT | 409 | Гонка INV-2: unique-нарушение (payout_order_id, recipient_user_id) | «Повторите попытку» | retry (перечитка позиции) |
REWARDS_CURRENCY_MISMATCH | 409 | Валюта добавляемой строки ≠ валюте поручения (INV-4) | «Валюта выплаты не совпадает с валютой поручения» | no retry |
REWARDS_PAYOUT_SUM_MISMATCH | 500 | Рассинхрон Item.Amount/TotalAmount/ItemsCount (INV-3, дефект расчёта) | «Внутренняя ошибка расчёта» | retry после fix |
Предусловия и постусловия
- Предусловия. Поручение существует в tenant-е агентства и находится в статусе
Draft. Актор — руководитель/бухгалтер с правомrewards.payouts.manage. Добавляемые строки —Agent-строки свободных (PayoutOrderItemId = null) начислений вMoneyReceivedтого же tenant-а; исключаемые — строки, включённые в позиции этого поручения. - Постусловия (успех). Состав пересобран: добавленные строки привязаны к позициям (одна позиция на
агента, INV-1/INV-2), исключённые освобождены (
PayoutOrderItemId → null, INV-8), опустевшие позиции удалены;Amountкаждой позиции иTotalAmount/ItemsCountпересчитаны (INV-3); валюта всех строк равна валюте поручения (INV-4).StatusостаётсяDraft,Version + 1. Интеграционных/доменных событий нет.
Acceptance-критерии
- Happy path (добавление и исключение). Дано: поручение в
Draft,AddDistributionIds— свободныеAgent-строки начислений вMoneyReceivedдвух агентов,RemoveDistributionIds— одна строка из позиции этого поручения, корректныйExpectedVersion. Когда:PUT .../items. Тогда:200, добавленные строки сгруппированы в позиции по агентам, исключённая освобождена,TotalAmount/ItemsCountпересчитаны,Status = Draft,Version + 1; событий нет. - Пустая дельта. Дано: оба списка пусты (или отсутствуют). Когда:
PUT. Тогда:400ValidationError(нужен хотя бы один непустой список). - Пересечение списков. Дано: один и тот же id в
AddDistributionIdsиRemoveDistributionIds. Когда:PUT. Тогда:400ValidationError(add ∩ remove должно быть пусто). - Состав заморожен. Дано: поручение уже
Approve-нуто (Status != Draft). Когда:PUT. Тогда:409REWARDS_PAYOUT_COMPOSITION_LOCKED(INV-5), состав не тронут. - Недоступная строка. Дано: добавляемая строка уже занята другим поручением, либо не
Agent, либо её начисление неMoneyReceived, либо чужой tenant. Когда:PUT. Тогда:409REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE, состав не изменён; клиент перечитывает пул свободных строк. - Снятие чужой строки. Дано:
RemoveDistributionIdsсодержит строку, не принадлежащую этому поручению (или уже свободную). Когда:PUT. Тогда:409REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE. - Несовпадение валют. Дано: валюта добавляемой строки ≠ валюте поручения. Когда:
PUT. Тогда:409REWARDS_CURRENCY_MISMATCH(INV-4). - Гонка за строку с параллельным поручением. Дано: две правки разных
Draft-поручений добавляют одну и ту же свободную строку. Когда: обе стартуют. Тогда: строку захватывает первый (блокировка с пропуском занятых), второй получаетREWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE— двойного включения нет. - Гонка с Approve/Cancel. Дано: параллельно с правкой поручение утверждается или отменяется. Когда:
PUTс устаревшимExpectedVersion. Тогда:409REWARDS_CONCURRENCY_CONFLICT. - Опустевшая позиция. Дано: из позиции агента исключены все его строки. Когда:
PUT. Тогда:200, позиция агента удалена,ItemsCountуменьшен,TotalAmountпересчитан.
Совместимость и наблюдаемость
AddDistributionIds/RemoveDistributionIds— additive optional; удаление/переименование полей или смена delta-семантики — breaking (migration + rollout). Ответ — общийPayoutOrderModel.- Logs:
OrderId,TenantId, countsadded/removed/ItemsCount,Version(суммы — Sensitive, не логируются); metrics:rewards_payout_items_update_total{result},rewards_payout_distribution_unavailable_total; traces — OTel, correlation черезtraceparent; dashboardrewards-overview; runbook —runbooks/rewards-accrual-replay.md(по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (8): GET /api/v1/agency/payout-orders — Список платёжных поручений (история выплат), GET /api/v1/my/payouts — Мои выплаты (агент), POST /api/v1/agency/commission-accruals/{accrualId}/adjust-distribution — Per-deal корректировка долей внутри пула, POST /api/v1/agency/payout-orders — Создание платёжного поручения, POST /api/v1/agency/payout-orders/{orderId}/approve — Утверждение платёжного поручения, POST /api/v1/agency/payout-orders/{orderId}/cancel — Отмена черновика поручения, POST /api/v1/agency/payout-orders/{orderId}/mark-paid — Ручная отметка о выплате, Rewards API — реестр операций и общие контрактные модели
- Use Cases (2): UC-RWD-005. Агентство настраивает распределение и формирует платёжное поручение, US-реестр: Владелец агентства (US-OWN-AG)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность и инварианты: domain/payout-order.md (INV-1/2/3/4/5/8/9).
- Источник строк распределения: domain/commission-accrual.md
(
CommissionDistributionEntity,PayoutOrderItemId). - Контрактные модели
PayoutOrderModel/PayoutOrderItemModel, paging, общие ошибки — README.md. - Смежные операции: create-payout-order.md (автосборка), approve-payout-order.md (заморозка состава), cancel-payout-order.md (освобождение строк).