POST /api/v1/admin/portfolio/properties — Создание записи портфеля вручную (admin, компенсирующее)
Admin-операция компенсирующего создания записи InvestorPropertyEntity вручную (Служба заботы).
Парная к archive-investor-property.md: исправление ошибочной записи
портфеля выполняется как «архивирование старой + создание новой» с аудитом — физического редактирования
иммутабельной покупки нет (INV-9, ../../domain/investor-property.md).
Дом полей агрегата и инварианты INV-1..9 — ../../domain/investor-property.md;
общие модели ответа портфеля (MoneyModel, PropertyObjectSnapshotModel) — README.md.
Назначение
Дать Службе заботы ручной ввод объекта в портфель инвестора, когда автоматические пути (consumer
pin.crm.deal-completed.v1 или синхронизация Talent Owners) отработали неверно или неприменимы: восстановить
пропавшую запись, пересоздать исправленную взамен архивированной, завести владение вне синхронизации.
Owning-контекст — investor-owner, feature group portfolio, API area admin.
| Поле | Значение |
|---|
| Сервис/контекст | investor-owner (admin area) |
| API area | admin (Служба заботы; JWT оператора + permission) |
| Feature group | portfolio |
| Статус | draft |
| Документ | docs/06-services/investor-owner/api/admin/create-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 | не применяется на входе (актор ≠ владелец); созданная запись принадлежит InvestorUserId из тела |
| Tenant isolation | не применяется — инвестор вне tenant (ADR-0052) |
| Audit | create (привилегированная операция): CreatedByUserId = actor sub, обязательный Reason, доменное событие InvestorPropertyAdded |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/admin/portfolio/properties |
| Success status | 201 (создано) |
| Idempotency header | не применяется — естественный guard INV-1 (unique ux_investor_properties_investor_property_object_active) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
CreatedByUserId | identity claims (sub) | Guid | Да | валидный JWT оператора — 401 до операции | actor-stamping (аудит) |
InvestorUserId | body | Guid | Да | != Guid.Empty | владелец создаваемой записи |
PropertyObjectId | body | Guid | Да | != Guid.Empty | ссылка на объект недвижимости chessboard (без FK) |
ProjectId | body | Guid? | Нет | — | денормализация для группировки/оценки |
BuildingId | body | Guid? | Нет | — | денормализация карточки |
DeveloperId | body | Guid? | Нет | — | денормализация карточки |
PropertyObjectSnapshot | body | PropertyObjectSnapshotInput | Да | Title непустой (1..256), SnapshotAt задан; поля-мирроры VO PropertyObjectSnapshot (README.md) | иммутабельный снапшот «что купили» |
PurchasePrice | body | MoneyInput | Да | Amount > 0 (INV-4); CurrencyId — существующая неархивная валюта справочника handbook (резолв ниже) | финансовые данные (не в логи) |
PurchasedAt | body | DateTimeOffset | Да | <= now() | дата покупки |
SalePrice | body | MoneyInput? | Нет (обязателен вместе с SoldAt при вводе проданным) | Amount > 0 (INV-4); CurrencyId == PurchasePrice.CurrencyId (INV-5) | финансовые данные |
SoldAt | body | DateTimeOffset? | Нет (обязателен при SalePrice) | >= PurchasedAt (INV-3), <= now() | дата продажи |
Reason | body | string | Да | 1..1024, непустой после trim | обоснование заботы (аудит) |
PropertyObjectSnapshotInput = { Title, Number?, Project? {ProjectId, ProjectName},
Building? {BuildingId, BuildingName}, AreaTotal?, Floor?, SnapshotAt } (mirror VO
PropertyObjectSnapshot с вложенными ref-моделями); MoneyInput = { Amount decimal,
CurrencyId short — ссылка на справочник валют handbook
(../../../handbook/domain/currency.md), v1 — валюта RUB
(ADR-0048) }. CurrencyId валидируется через internal resolve справочника
(../../../handbook/api/internal/resolve-references.md) —
один батч-вызов на запрос; неизвестная или архивная валюта → INVESTOR_OWNER_CURRENCY_UNKNOWN. Source,
DealId, ExternalOwnershipId, OwnerMappingId не принимаются от актора — задаются доменом
(см. «Доменное расширение» ниже). SalePrice+SoldAt передаются вместе (обе или ни одной), иначе ValidationError.
Доменное расширение: PropertySource.AdminCorrection
Ручной ввод не укладывается в существующие значения enum PropertySource
(../../domain/investor-property.md, «Enum PropertySource»): PinCrmDeal
требует DealId, TalentOwnersSync требует ExternalOwnershipId + OwnerMappingId (INV-2 SourceConsistency).
Операция вводит третье значение и требует его добавления в доме enum-а:
| Значение | DealId | ExternalOwnershipId | OwnerMappingId | Правило |
|---|
AdminCorrection | null | null | null | Компенсирующее ручное создание Службой заботы; идентичность записи — Id + аудит (CreatedByUserId, Reason), внешних ключей идемпотентности нет. |
INV-2 ослабляется: Source = AdminCorrection ⇔ DealId = null ∧ ExternalOwnershipId = null ∧ OwnerMappingId = null.
Переиспользовать PinCrmDeal/TalentOwnersSync нельзя — обязательные внешние id отсутствуют, а фиктивные
значения нарушили бы идемпотентность consumer-ов (ux_investor_properties_deal_id / ..._external_ownership_id).
Ручной ввод — третий и единственный путь создания записи наравне с двумя автоматическими источниками.
Модель ответа InvestorPropertyModel
Проекция созданной записи; поля — дом ../../domain/investor-property.md,
MoneyModel/PropertyObjectSnapshotModel — README.md.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
id | Guid | Да | Id | — | Id созданной записи. |
investorUserId | Guid | Да | InvestorUserId | — | Владелец записи. |
propertyObjectId | Guid | Да | PropertyObjectId | — | Объект недвижимости (chessboard). |
projectId | Guid? | Нет | ProjectId | — | Проект; null — без сопоставления с шахматкой. |
buildingId | Guid? | Нет | BuildingId | — | Здание/литер. |
developerId | Guid? | Нет | DeveloperId | — | Застройщик. |
status | string (enum OwnershipStatus) | Да | Status | — | Active / Sold (при вводе проданным). |
source | string (enum PropertySource) | Да | Source | — | всегда AdminCorrection. |
propertyObject | PropertyObjectSnapshotModel | Да | PropertyObjectSnapshot | — | Снапшот «что купили». |
purchasePrice | MoneyModel | Да | PurchasePrice | только уполномоченным | За сколько купили. |
purchasedAt | string (ISO 8601) | Да | PurchasedAt | — | Когда купили. |
salePrice | MoneyModel? | Нет (при status = Sold) | SalePrice | только уполномоченным | За сколько продали. |
soldAt | string (ISO 8601)? | Нет (при status = Sold) | SoldAt | — | Когда продали. |
realizedProfit | MoneyModel? | Нет (при status = Sold) | RealizedProfit (вычислен при продаже, INV-5) | только уполномоченным | Сколько заработали (< 0 — убыток). |
createdByUserId | Guid | Да | CreatedByUserId | — | Оператор-автор (аудит). |
reason | string | Да | echo Reason | — | Обоснование правки. |
createdAt | string (ISO 8601) | Да | CreatedAt | — | Момент создания. |
version | long | Да | Version (= 1 при создании) | — | Доменная версия. |
Алгоритм
- Контекст и доступ. Операцию выполняет оператор Службы заботы (
CreatedByUserId — из claim sub);
требуется permission investor-owner.portfolio.manage (иначе 403); scope платформенный.
- Проверка входа и предусловий.
InvestorUserId/PropertyObjectId != Guid.Empty; PurchasePrice.Amount > 0 (INV-4);
PurchasedAt <= now(); Reason непустой; PropertyObjectSnapshot.Title непустой, SnapshotAt задан;
связка продажи — SalePrice⇔SoldAt (обе/ни одной), при наличии SalePrice.Amount > 0 (INV-4),
SalePrice.CurrencyId == PurchasePrice.CurrencyId (INV-5), SoldAt >= PurchasedAt (INV-3) и
SoldAt <= now() → иначе ValidationError.
- Резолв валюты.
PurchasePrice.CurrencyId (единственный CurrencyId запроса — INV-5) резолвится
батч-вызовом internal resolve справочника handbook: не найден или архивный →
INVESTOR_OWNER_CURRENCY_UNKNOWN (422); справочник недоступен → 503 (повторить позже, портфель не
мутирует). Резолв возвращает display-поля валюты (IsoCode) для денормализации и снапшота событий.
- INV-1 SingleActivePerLot: если у инвестора уже есть
Active-запись по паре
(InvestorUserId, PropertyObjectId) → INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE (сначала архивировать
существующую через archive-investor-property).
- Существование
InvestorUserId/PropertyObjectId в чужих контекстах синхронно не проверяется
(ссылки без FK; ответственность оператора — фиксируется в Reason).
- Создание. Создаётся запись портфеля:
Source = AdminCorrection, DealId/ExternalOwnershipId/
OwnerMappingId = null (INV-2 relaxed), Status = Active, Version = 1 (переход [*] → Active);
денормализованный CurrencyIsoCode — код резолвленной валюты (шаг 2).
Фиксируется событие появления (см. шаг 4). Если переданы SalePrice+SoldAt — запись сразу переводится
в Sold: вычисляется RealizedProfit.Amount = SalePrice.Amount − PurchasePrice.Amount (INV-5),
проверяются INV-3/4/5, статус Active → Sold тем же запросом.
- События. Публикуется
InvestorPropertyAddedKafkaEvent → topic pin.investor-owner.property-added.v1
(ContractVersion = 1, key/AggregateId = Id; payload {Id, InvestorUserId, PropertyObjectId, ProjectId, Source = AdminCorrection, PurchasePrice{Amount,CurrencyId}, Currency (CurrencySnapshot резолвленной валюты), PurchasedAt, Version} — контракт
../../events/property-added.md). Если создано сразу проданным —
дополнительно InvestorPropertySoldKafkaEvent → topic pin.investor-owner.property-sold.v1
(ContractVersion = 1, AggregateId = Id; payload {Id, InvestorUserId, PropertyObjectId, SalePrice{Amount,CurrencyId}, SoldAt, RealizedProfit{Amount,CurrencyId}, Currency (CurrencySnapshot), Version}). Создание записи и оба
события фиксируются в одном согласованном изменении.
- Результат.
201 с моделью InvestorPropertyModel (суммы — только уполномоченным). Конфликт INV-1 →
INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE (409).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | InvestorPropertyAddedKafkaEvent → pin.investor-owner.property-added.v1 (всегда); InvestorPropertySoldKafkaEvent → pin.investor-owner.property-sold.v1 (только при вводе проданным) |
| Внешние вызовы | internal resolve справочника handbook (один батч-вызов: валидация CurrencyId + display-поля валюты); chessboard/identity синхронно не вызываются (снапшот объекта недвижимости из тела — ручной ввод авторитетен) |
| Проекции / поиск | none (портфель без search-индексов, ADR-0056) |
| Уведомления | опосредованно: notifications/user-activity — консюмеры property-added/property-sold |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен/невалиден JWT | без тела схемы | re-login |
FORBIDDEN | 403 | Нет investor-owner.portfolio.manage | «Недостаточно прав» | no retry |
ValidationError | 400 | Пустые id; PurchasePrice.Amount <= 0; PurchasedAt > now; неполная/несогласованная пара продажи; пустой Reason | Список ошибок полей | no retry |
INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE | 409 | Уже есть Active-запись по (InvestorUserId, PropertyObjectId) — INV-1 | «Активная запись по этому объекту недвижимости уже существует» | no retry (сначала архивировать) |
INVESTOR_OWNER_PRICE_INVALID | 422 | Нарушение INV-4 при продаже (цена <= 0) | «Некорректная сумма» | no retry |
INVESTOR_OWNER_SALE_DATA_INCOMPLETE | 422 | Нарушение INV-3 при переводе в Sold (SoldAt < PurchasedAt / неполнота) | «Некорректные данные продажи» | no retry |
INVESTOR_OWNER_CURRENCY_MISMATCH | 422 | Нарушение INV-5 (SalePrice.CurrencyId != PurchasePrice.CurrencyId) | «Валюты не совпадают» | no retry |
INVESTOR_OWNER_CURRENCY_UNKNOWN | 422 | CurrencyId отсутствует в справочнике валют handbook или валюта архивная | «Неизвестная валюта» | no retry (исправить CurrencyId) |
| — (dependency) | 503 | Справочник handbook недоступен при резолве валюты | «Сервис временно недоступен» | повторить позже; портфель не мутирует |
Совместимость и наблюдаемость
PropertySource.AdminCorrection — additive значение enum (правило совместимости
../../events/property-added.md): без bump ContractVersion; consumer-ы по
unknown-enum используют нейтральный шаблон. Новые optional поля тела/ответа — additive; удаление/смена
типа/семантики — breaking (migration + rollout admin-UI).
- Logs:
create-investor-property received/created (InvestorUserId, PropertyObjectId, PropertyId,
CreatedByUserId, признак «создано проданным») — без сумм и Reason открытым текстом; metrics:
investor_owner_admin_property_created_total{sold}; traces — сквозной trace через traceparent; dashboard
investor-owner-overview; runbook «компенсирующее исправление записи портфеля» (../../README.md).
Acceptance Criteria
- Given оператор заботы и инвестор без активной записи по объекту, When
POST /admin/portfolio/ properties с валидной покупкой и Reason, Then 201 с записью Source = AdminCorrection,
Status = Active, Version = 1, DealId/ExternalOwnershipId/OwnerMappingId = null (INV-2 relaxed);
публикуется InvestorPropertyAddedKafkaEvent в том же согласованном изменении.
- Given тот же запрос с согласованной парой
SalePrice+SoldAt, When создание, Then запись
сразу переводится в Sold, RealizedProfit = SalePrice − PurchasePrice (INV-5), и дополнительно
публикуется InvestorPropertySoldKafkaEvent.
- Given у инвестора уже есть
Active-запись по паре (InvestorUserId, PropertyObjectId), When
создание, Then INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE (409) — сначала архивировать существующую.
- Given
SalePrice без SoldAt (или наоборот), When запрос, Then ValidationError (400) — пара
передаётся целиком.
- Given
PurchasePrice.Amount <= 0, Then INVESTOR_OWNER_PRICE_INVALID (422); Given
SoldAt < PurchasedAt/неполные данные продажи, Then INVESTOR_OWNER_SALE_DATA_INCOMPLETE (422);
Given SalePrice.CurrencyId != PurchasePrice.CurrencyId, Then INVESTOR_OWNER_CURRENCY_MISMATCH
(422).
- Given
CurrencyId, отсутствующий в справочнике handbook или архивный, When запрос, Then
INVESTOR_OWNER_CURRENCY_UNKNOWN (422); Given недоступный справочник при резолве, Then 503,
портфель не мутирует (повтор после восстановления).
- Given пустые id/
Reason или PurchasedAt > now, When запрос, Then ValidationError (400);
Given нет investor-owner.portfolio.manage, Then 403; невалидный JWT — 401.
- Given несуществующие
InvestorUserId/PropertyObjectId в чужих контекстах, When создание, Then
операция всё равно проходит (ссылки без FK, ответственность оператора зафиксирована в Reason);
chessboard/identity синхронно не вызываются.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы