POST /api/v1/admin/portfolio/properties/{propertyId}/archive — Архивирование записи портфеля (admin, archiveInvestorProperty)
Назначение
Компенсирующая admin-операция Службы заботы: мягкое архивирование (soft-delete) ошибочной записи
портфеля инвестора. Основной сценарий — разбор дублей INV-1 SingleActivePerLot: у инвестора оказались
две Active-записи на один объект недвижимости (одна из синхронизации Talent Owners, вторая из завершённой
сделки PIN); оператор архивирует лишнюю, после чего событие переигрывается из DLQ (см. оба consumer-а:
../../consumers/crm-deal-completed.md,
../../consumers/talent-owners-sync.md). Также применяется к исправлению
любых ошибочных записей (INV-9 ImmutablePurchase: цены/даты покупки иммутабельны — правка только через
архивирование + новая запись create-investor-property.md).
Физическое удаление запрещено: запись помечается DeletedAt и остаётся в БД (история заработка, аудит).
Дом полей — ../../domain/investor-property.md.
| Поле | Значение |
|---|
| Сервис/контекст | investor-owner |
| API area | admin (Служба заботы) |
| Feature group | portfolio |
| Статус | draft |
| Документ | docs/06-services/investor-owner/api/admin/archive-investor-property.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Operator(Admin) (Служба заботы, Bearer JWT) |
| Auth policy / Permissions | authenticated; permission investor-owner.portfolio.manage (../../../identity/domain/permission.md) |
| Scope (RBAC) | платформенный (не self): оператор архивирует запись любого инвестора |
| Record-level | не применяется: доступ гейтит permission portfolio.manage; запись читается по Id без фильтра владельца |
| Tenant isolation | не применяется — инвестор вне tenant (ADR-0052) |
| Audit | delete (привилегированная правка): DeletedByUserId = actor + Reason + доменное событие InvestorPropertyArchived; deep-audit доступа к чужим финансовым данным — ADR-0053 |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/admin/portfolio/properties/{propertyId}/archive |
| Success status | 204 (No Content); идемпотентный повтор на уже архивированной — тоже 204 |
| Idempotency header | не применяется; доменная идемпотентность — повторный архив = no-op |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims (sub) | Guid | Да | валидный JWT — 401 до операции; → DeletedByUserId | id оператора заботы |
PropertyId | route {propertyId} | Guid | Да | != Guid.Empty (иначе 404 route-binding / ValidationError) | id записи портфеля, не PropertyObjectId |
Reason | body | string | Да | непустой после trim, 1..512; фиксируется в аудит (доменное событие) | операционный текст, не PII |
Модель ответа
Полезной нагрузки нет: успех отдаёт 204 No Content. Признак «уже была архивирована» наружу не выводится
(идемпотентность прозрачна для оператора).
Алгоритм
- Контекст и доступ. Операцию выполняет оператор Службы заботы (
ActorUserId — из claim sub);
требуется permission investor-owner.portfolio.manage (иначе 403); scope платформенный (не self).
- Проверка входа и предусловий.
PropertyId != Guid.Empty; Reason непустой после trim, длина
1..512 (обязателен для аудита) — иначе ValidationError. Найти запись портфеля по PropertyId,
включая уже архивированные (для идемпотентности повторного архива); если записи нет →
INVESTOR_OWNER_PROPERTY_NOT_FOUND. Статус не проверяется — архивируется запись в любом статусе
(Active и Sold; переход soft-delete из обоих —
../../domain/investor-property.md).
- Идемпотентность. Если запись уже архивирована (
DeletedAt задан) → идемпотентный no-op: лог
skipped-already-archived, метрика investor_owner_property_archive_noop_total, операция завершается
204 без мутации и события.
- Архивирование. Мягкое удаление:
DeletedAt = now(), DeletedByUserId = ActorUserId, Reason
фиксируется, версия записи растёт. Физического удаления нет. Фиксируется доменное событие
InvestorPropertyArchived (Id, InvestorUserId, PropertyObjectId, Reason, DeletedByUserId) —
только внутренний аудит. Публичного Kafka-события об удалении НЕТ (property-removed не объявлен) —
осознанное решение минимальности (ADR-0056): архив — редкая компенсирующая правка заботы; потребители
портфеля (notifications, user-activity) рассылку об удалении не получают, реконсиляция — через
replay источника.
- Результат.
204 (включая идемпотентный no-op). Ошибки — таблица ниже.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | none — Kafka-событие об удалении не публикуется (осознанно, ADR-0056; property-removed не существует) |
| Внешние вызовы | none |
| Проекции / поиск | none (частичный индекс ux_investor_properties_investor_property_object_active where deleted_at is null — архив освобождает объект недвижимости под INV-1) |
| Уведомления | none |
| Реконсиляция | после архива дубля INV-1 — точечный replay из DLQ соответствующего consumer-а (архивированная по DealId/ExternalOwnershipId запись синхронизацией/сделкой не пересоздаётся); runbook «компенсирующее исправление записи портфеля» |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен/невалиден JWT | без тела схемы | re-login |
ValidationError | 400 | PropertyId пустой/не Guid; Reason пустой/длиннее 512 | Список ошибок полей | no retry |
FORBIDDEN | 403 | Нет permission investor-owner.portfolio.manage | «Недостаточно прав» | no retry |
INVESTOR_OWNER_PROPERTY_NOT_FOUND | 404 | Записи с PropertyId нет (в т.ч. уже архивированной) | «Объект портфеля не найден» | no retry |
| — (идемпотентный no-op, не ошибка) | 204 | DeletedAt задан (уже архивирована) | без тела | — |
| — (concurrency) | 409 | Гонка с параллельной правкой записи | «Повторите попытку» | retry (перечитать → повтор) |
Совместимость и наблюдаемость
Reason обязателен с v1; добавление optional-полей body — additive; удаление/смена типа —
breaking (rollout admin-UI). Отсутствие property-removed-события — сознательный контрактный минимум; его
введение (если понадобится реконсиляция без replay) — additive новый топик pin.investor-owner.*.
- Logs:
archived / skipped-already-archived / not-found с полями PropertyId, InvestorUserId,
PropertyObjectId, DeletedByUserId, Reason (операционный текст) — без сумм (финансовые данные,
правило дома ../../domain/investor-property.md); metrics:
investor_owner_property_archived_total, investor_owner_property_archive_noop_total, latency histogram;
traces — сквозной trace через traceparent; dashboard investor-owner-overview; runbook «компенсирующее
исправление записи портфеля».
Acceptance Criteria
- Given оператор заботы и существующая не-архивированная запись (в любом статусе
Active/Sold),
When POST /archive c непустым Reason, Then 204, запись помечена DeletedAt/DeletedByUserId,
Reason зафиксирован, версия выросла, публикуется доменное InvestorPropertyArchived; Kafka-события об
удалении НЕ уходит.
- Given запись уже архивирована (
DeletedAt задан), When повторный POST /archive, Then 204
(идемпотентный no-op): без мутации, без события, лог skipped-already-archived, инкремент
investor_owner_property_archive_noop_total.
- Given архивирование одной из двух
Active-записей по объекту (дубль INV-1), When операция успешна,
Then частичный индекс ux_investor_properties_investor_property_object_active освобождает объект под
INV-1, и точечный replay из DLQ соответствующего consumer-а проходит (архивированная по
DealId/ExternalOwnershipId запись синхронизацией/сделкой НЕ пересоздаётся).
- Given
propertyId не существует, When POST /archive, Then INVESTOR_OWNER_PROPERTY_NOT_FOUND
(404).
- Given пустой/невалидный
propertyId или Reason пустой/длиннее 512, When запрос, Then
ValidationError (400).
- Given отсутствует
investor-owner.portfolio.manage, When запрос, Then 403; невалидный JWT —
401. Доступ к чужим финансовым данным фиксируется deep-audit (ADR-0053).
- Given параллельная правка той же записи, When архивирование, Then проигравший получает
409
(concurrency) и повторяет после перечитки.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы