POST /api/v1/agency/rewards-exports — Постановка Excel-выгрузки вознаграждений агентства
Асинхронная выгрузка отчётов агентства по вознаграждениям в XLSX
(ADR-0041): операция создаёт
задачу выгрузки (RewardsExportJob), фоновый исполнитель формирует файл и кладёт его в приватное объектное
хранилище; статус и короткоживущая ссылка на скачивание — get-rewards-export-job.md.
Контракты самих отчётов: ../../reports/agency-rewards-by-employee-per-deal.md
и ../../reports/agency-rewards-period-summary.md.
Назначение
Владелец/руководитель агентства выгружает в Excel картину вознаграждений: детально по сотрудникам и сделкам
(ByEmployeePerDeal) или сводно по агентам за период (PeriodSummary) — для бухгалтерии, сверки выплат и
управленческой отчётности. Генерация асинхронная — большие периоды не блокируют API и не нарушают SLA.
Feature group — rewards-exports.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | agency |
| Feature group | rewards-exports |
| Статус | draft |
| Документ | docs/06-services/rewards/api/agency/create-rewards-export.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (владелец/руководитель агентства) |
| Auth policy / Permissions | rewards.commission.view-all (выгрузка не раскрывает больше, чем видно в списках агентства) |
| Scope (RBAC) | организация: компания Type ∈ {Agency, Broker, Pin} |
| Record-level | не применяется (выборка ограничена tenant-ом) |
| Tenant isolation | TenantId = CompanyId агентства из claims (не из body); в файл попадают только начисления/поручения своего tenant |
| Audit | ExportSensitive: обязательная запись на каждую постановку (актор, ReportType, фильтры, время); финальный аудит (число строк, исход) — при завершении задачи |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agency/rewards-exports |
| Success status | 202 (+ Location: /api/v1/agency/rewards-exports/{jobId}) |
| Idempotency header | не применяется; естественная защита от двойного клика: повтор с теми же (ReportType, фильтры), пока активна незавершённая задача, возвращает 202 с тем же JobId |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
TenantId | identity claims (tenant_id) | Guid | Да | Required; не из body | Tenant isolation |
ActorUserId | identity claims (sub) | Guid | Да | Required | audit |
ReportType | body | string (enum RewardsReportType) | Да | ByEmployeePerDeal / PeriodSummary | — |
PeriodFrom | body | DateTimeOffset | Да | Required; строки отбираются по DealCompletedAt >= PeriodFrom | — |
PeriodTo | body | DateTimeOffset | Да | PeriodTo > PeriodFrom (исключительно: DealCompletedAt < PeriodTo); диапазон ≤ 1 года — иначе REWARDS_EXPORT_PERIOD_TOO_WIDE | — |
AgentUserId | body | Guid? | Нет | если задан — непустой; фильтр «только этот агент» | — |
Status | body | string? (enum CommissionAccrualStatus) | Нет | PendingConfirmation/Confirmed/MoneyReceived/Paid/Cancelled; пусто = все статусы | — |
Enum RewardsReportType
Модель ответа RewardsExportJobModel
Дом модели — README.md («Общие контрактные модели»). На постановке: Status = Queued,
DownloadUrl = null.
Алгоритм
- Контекст и доступ. Операцию выполняет владелец/руководитель агентства; scope — организация
(
TenantId из claims); требуется право rewards.commission.view-all.
- Проверка входа.
ReportType — известное значение enum; PeriodTo > PeriodFrom; диапазон периода
≤ 1 года — иначе REWARDS_EXPORT_PERIOD_TOO_WIDE; Status — известное значение enum, если задан;
AgentUserId непустой, если задан (принадлежность агента агентству не проверяется — чужой id даст
пустой отчёт, данные ограничены tenant-ом).
- Дедупликация двойного клика. Если у tenant-а уже есть незавершённая задача (
Queued/Running)
с теми же ReportType и фильтрами — новая не создаётся, возвращается 202 с её JobId.
- Лимит конкурентности. Активных задач (
Queued/Running) у tenant-а — не более 2; превышение →
REWARDS_EXPORT_CONCURRENCY_LIMIT (уже идущие задачи не прерываются).
- Постановка задачи. В одном согласованном изменении создаются задача выгрузки
(
Status = Queued, ReportType, снапшот фильтров, актор) и audit-запись ExportSensitive
(актор, фильтры, время). Ответ — 202 + Location.
- Фоновая генерация (вне запроса). Исполнитель читает данные (
rewards.commission_accruals +
распределения + поручения; ФИО агентов и названия застройщиков — обогащение из identity на момент
генерации), формирует строки и итоги по контракту отчёта, проверяет лимит 100 000 строк
(превышение → задача Failed с кодом REWARDS_EXPORT_ROW_LIMIT_EXCEEDED — пользователь сужает период
или фильтры), сериализует XLSX и кладёт файл в приватное объектное хранилище; задача → Completed
(или Failed с кодом причины — частичный файл никогда не публикуется). Retention файла — 7 дней,
после — задача Expired, файл удаляется.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | none (задача выгрузки — внутренняя сущность rewards) |
| Внешние вызовы | фоновый исполнитель: identity (ФИО агентов, названия компаний — enrichment при генерации, недоступность → retry задачи, не HTTP-запроса); объектное хранилище (файл) |
| Уведомления | none (клиент опрашивает статус задачи) |
Ошибки и failure modes
Общие коды (ValidationError, FORBIDDEN, UNAUTHORIZED) — дом README.md «Общие
ошибки». Дельта операции (HTTP-коды — на постановке; REWARDS_EXPORT_ROW_LIMIT_EXCEEDED фиксируется в
задаче, не в HTTP-ответе):
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_EXPORT_PERIOD_TOO_WIDE | 400 | PeriodTo − PeriodFrom > 1 года | «Период выгрузки не может превышать один год» | no retry (сузить период) |
REWARDS_EXPORT_CONCURRENCY_LIMIT | 429 | У tenant-а уже 2 активные задачи (Queued/Running) | «Дождитесь завершения текущих выгрузок» | retry после завершения |
REWARDS_EXPORT_ROW_LIMIT_EXCEEDED | — (статус задачи Failed) | Выборка превысила 100 000 строк | «Слишком большая выборка — сузьте период или фильтры» | no retry (сузить фильтры) |
Acceptance criteria
- AC-1 (постановка и генерация). Given актор с
rewards.commission.view-all, валидные
ReportType = ByEmployeePerDeal и период ≤ 1 года; When POST; Then 202 + Location с
JobId, задача Queued, записан audit ExportSensitive; фоновый исполнитель доводит задачу до
Completed, файл XLSX доступен через get-rewards-export-job.md.
- AC-2 (двойной клик). Given активная задача с теми же
ReportType и фильтрами; When
повторный POST; Then 202 с тем же JobId — вторая задача не создаётся.
- AC-3 (лимит конкурентности). Given у tenant-а уже 2 активные задачи; When
POST с другими
фильтрами; Then REWARDS_EXPORT_CONCURRENCY_LIMIT (429); активные задачи не прерываются.
- AC-4 (период шире года). Given
PeriodTo − PeriodFrom = 18 месяцев; When POST; Then
REWARDS_EXPORT_PERIOD_TOO_WIDE (400), задача не создаётся.
- AC-5 (превышение лимита строк). Given выборка за период даёт > 100 000 строк; When фоновая
генерация; Then задача →
Failed с REWARDS_EXPORT_ROW_LIMIT_EXCEEDED, частичный файл не
публикуется; аудит фиксирует исход.
- AC-6 (изоляция tenant). Given актор агентства A; When выгрузка; Then в файле только
начисления/поручения агентства A (
TenantId из claims); AgentUserId чужого агентства даёт пустой отчёт
(строки нулевые, totals нулевые) — не ошибка.
- AC-7 (доступ). Given актор без
rewards.commission.view-all (например, агент); When POST;
Then FORBIDDEN (403), задача и аудит-запись выгрузки не создаются (пишется запись отказа доступа).
Совместимость и наблюдаемость
- Контракт additive-only: новые optional фильтры и новые значения
RewardsReportType — additive
(unknown value от старого клиента невозможен — enum валидируется); смена структуры файла — версия
контракта соответствующего отчёта.
- Logs:
JobId, TenantId, ReportType, result (фильтры — в audit, суммы не логируются); metrics:
rewards_export_jobs_total{report_type,result}, rewards_export_duration_seconds,
rewards_export_rows (гистограмма); traces: OTel, correlation через traceparent; dashboard
rewards-overview.
- НФТ (ADR-0056): единицы выгрузок на агентство в месяц; генерация 100k строк — минуты (фон, не SLA API).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы