Перейти к основному содержимому

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 areaagency
Feature grouppayouts
Статусdraft
Документdocs/06-services/rewards/api/agency/update-payout-order-items.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (руководитель / бухгалтер агентства)
Auth policy / Permissionsrewards.payouts.manage (op23 реестра README.md)
Scope (RBAC)организация: компания Type ∈ {Agency, Broker, Pin} (агентство-владелец поручения)
Record-levelorder.TenantId == CompanyId актора (tenant-интерсептор); чужое поручение → 404
Tenant isolationSame-tenant: поручение, позиции и строки распределения — в tenant-е агентства (INV-9); загрузка ограничена tenant-ом агентства
Auditupdate: UpdatedAt, UpdatedByUserId = ActorUserId, Version + 1; статус не меняется (остаётся Draft); финансовых событий нет

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/v1/agency/payout-orders/{orderId}/items
Success status200
Idempotency headerне применяется (тело — дельта add/remove, не полный snapshot; защита — ExpectedVersion, повтор → REWARDS_CONCURRENCY_CONFLICT/REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequiredtenant isolation
ActorUserIdidentity claims (sub)GuidДаRequired
OrderIdroute {orderId}GuidДаRequired, != Guid.Empty
ExpectedVersionbodylongДа>= 1; сверка с PayoutOrderEntity.Versionoptimistic concurrency (конкурент — Approve/Cancel)
AddDistributionIdsbodyIReadOnlyList<Guid>?Нетуникальны, <= 500; каждый — Agent-строка свободного начисления в MoneyReceived
RemoveDistributionIdsbodyIReadOnlyList<Guid>?Нетуникальны, <= 500; каждый — строка, включённая в позицию этого поручения

Инварианты валидатора: хотя бы один из списков непустой (иначе ValidationError); пересечение AddDistributionIds ∩ RemoveDistributionIds пусто.

Модель ответа PayoutOrderModel

Полная проекция PayoutOrderEntity — дом README.md (PayoutOrderModel). Дельта операции: после правки Items отдаётся полностью пересчитанным, а денормализации отражают новый состав.

ПолеТипОбязательностьИсточникMaskingОписание
Statusstring (enum PayoutOrderStatus)ДаStatusВсегда Draft (правка состава статус не меняет).
TotalAmountMoneyModelДаTotalAmountSensitiveПересчитанный итог = Σ Items[].Amount (INV-3).
ItemsCountintДаItemsCountПересчитанное число позиций.
ItemsIReadOnlyList<PayoutOrderItemModel>Даchild-коллекцияSensitive (Amount)Позиции по агентам после add/remove; опустевшая позиция удаляется.
VersionlongДаVersionНовая версия — использовать как ExpectedVersion следующего вызова.

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/бухгалтер агентства (тип компании Agency/Broker/Pin) — владелец поручения; scope — организация (TenantId из claims); требуется право rewards.payouts.manage.
  2. Проверка входа и предусловий.
    • формат: 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. Поручение читается под блокировкой вместе с позициями.
  3. Что читаем.
    • добавляемые строки: Agent-строки распределения по AddDistributionIds — годные только если принадлежат tenant-у агентства (INV-9), RecipientType = Agent, начисление в MoneyReceived (гейт получения денег пройден, ADR-0067), строка свободна (PayoutOrderItemId = null); захват под блокировкой с пропуском занятых. Если найдены не все (строка занята/не Agent/начисление не MoneyReceived/чужой tenant) → REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE;
    • исключаемые строки: строки по RemoveDistributionIds, каждая обязана указывать на позицию этого поручения; снятие чужой/свободной строки → REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE.
  4. Пересборка состава.
    • добавление — группировка добавляемых строк по агенту (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, растёт версия.
  5. Согласованность. Поручение, позиции и привязка строк распределения обновляются в одном согласованном изменении. Гонка за ту же строку с параллельным поручением разрешается захватом под блокировкой с пропуском занятых, гонка с Approve/Cancel — сверкой версии поручения. Интеграционных/доменных событий правка Draft не поднимает (события — только на переходах Approve/MarkPaid/Reject, domain/payout-order.md).
  6. Результат. Полная проекция поручения с пересчитанными Items (Status = Draft, новая версия) → HTTP 200.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (правка Draft — внутренняя)
External callsnone
Cache / projections / searchnone
Notificationsnone (уведомления агентам — при 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_FOUND404Поручение не существует или чужой tenant«Поручение не найдено»no retry
REWARDS_PAYOUT_COMPOSITION_LOCKED409Status != Draft (INV-5) — состав заморожен после утверждения«Состав поручения нельзя изменить»no retry
REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE409Добавляемая строка занята/не Agent/начисление не MoneyReceived/чужой tenant, либо исключаемая строка не принадлежит поручению (INV-1)«Строка распределения недоступна»retry после refresh
REWARDS_PAYOUT_DUPLICATE_RECIPIENT409Гонка INV-2: unique-нарушение (payout_order_id, recipient_user_id)«Повторите попытку»retry (перечитка позиции)
REWARDS_CURRENCY_MISMATCH409Валюта добавляемой строки ≠ валюте поручения (INV-4)«Валюта выплаты не совпадает с валютой поручения»no retry
REWARDS_PAYOUT_SUM_MISMATCH500Рассинхрон 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. Тогда: 400 ValidationError (нужен хотя бы один непустой список).
  • Пересечение списков. Дано: один и тот же id в AddDistributionIds и RemoveDistributionIds. Когда: PUT. Тогда: 400 ValidationError (add ∩ remove должно быть пусто).
  • Состав заморожен. Дано: поручение уже Approve-нуто (Status != Draft). Когда: PUT. Тогда: 409 REWARDS_PAYOUT_COMPOSITION_LOCKED (INV-5), состав не тронут.
  • Недоступная строка. Дано: добавляемая строка уже занята другим поручением, либо не Agent, либо её начисление не MoneyReceived, либо чужой tenant. Когда: PUT. Тогда: 409 REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE, состав не изменён; клиент перечитывает пул свободных строк.
  • Снятие чужой строки. Дано: RemoveDistributionIds содержит строку, не принадлежащую этому поручению (или уже свободную). Когда: PUT. Тогда: 409 REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE.
  • Несовпадение валют. Дано: валюта добавляемой строки ≠ валюте поручения. Когда: PUT. Тогда: 409 REWARDS_CURRENCY_MISMATCH (INV-4).
  • Гонка за строку с параллельным поручением. Дано: две правки разных Draft-поручений добавляют одну и ту же свободную строку. Когда: обе стартуют. Тогда: строку захватывает первый (блокировка с пропуском занятых), второй получает REWARDS_PAYOUT_DISTRIBUTION_UNAVAILABLE — двойного включения нет.
  • Гонка с Approve/Cancel. Дано: параллельно с правкой поручение утверждается или отменяется. Когда: PUT с устаревшим ExpectedVersion. Тогда: 409 REWARDS_CONCURRENCY_CONFLICT.
  • Опустевшая позиция. Дано: из позиции агента исключены все его строки. Когда: PUT. Тогда: 200, позиция агента удалена, ItemsCount уменьшен, TotalAmount пересчитан.

Совместимость и наблюдаемость

  • AddDistributionIds/RemoveDistributionIds — additive optional; удаление/переименование полей или смена delta-семантики — breaking (migration + rollout). Ответ — общий PayoutOrderModel.
  • Logs: OrderId, TenantId, counts added/removed/ItemsCount, Version (суммы — Sensitive, не логируются); metrics: rewards_payout_items_update_total{result}, rewards_payout_distribution_unavailable_total; traces — OTel, correlation через traceparent; dashboard rewards-overview; runbook — runbooks/rewards-accrual-replay.md (по конвенции).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы