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

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.

Metadata

ПолеЗначение
Сервис/контекстinvestor-owner (admin area)
API areaadmin (Служба заботы; JWT оператора + permission)
Feature groupportfolio
Статусdraft
Документdocs/06-services/investor-owner/api/admin/create-investor-property.md

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

ПроверкаЗначение
Actor typeOperator(Admin) — Служба заботы / Менеджер ПИН (Bearer JWT)
Auth policy / Permissionsauthenticated; permission investor-owner.portfolio.manage (../../../identity/domain/permission.md)
Scope (RBAC)платформенный (не self): оператор создаёт запись на инвестора из тела запроса
Record-levelне применяется на входе (актор ≠ владелец); созданная запись принадлежит InvestorUserId из тела
Tenant isolationне применяется — инвестор вне tenant (ADR-0052)
Auditcreate (привилегированная операция): CreatedByUserId = actor sub, обязательный Reason, доменное событие InvestorPropertyAdded

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/admin/portfolio/properties
Success status201 (создано)
Idempotency headerне применяется — естественный guard INV-1 (unique ux_investor_properties_investor_property_object_active)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
CreatedByUserIdidentity claims (sub)GuidДавалидный JWT оператора — 401 до операцииactor-stamping (аудит)
InvestorUserIdbodyGuidДа!= Guid.Emptyвладелец создаваемой записи
PropertyObjectIdbodyGuidДа!= Guid.Emptyссылка на объект недвижимости chessboard (без FK)
ProjectIdbodyGuid?Нетденормализация для группировки/оценки
BuildingIdbodyGuid?Нетденормализация карточки
DeveloperIdbodyGuid?Нетденормализация карточки
PropertyObjectSnapshotbodyPropertyObjectSnapshotInputДаTitle непустой (1..256), SnapshotAt задан; поля-мирроры VO PropertyObjectSnapshot (README.md)иммутабельный снапшот «что купили»
PurchasePricebodyMoneyInputДаAmount > 0 (INV-4); CurrencyId — существующая неархивная валюта справочника handbook (резолв ниже)финансовые данные (не в логи)
PurchasedAtbodyDateTimeOffsetДа<= now()дата покупки
SalePricebodyMoneyInput?Нет (обязателен вместе с SoldAt при вводе проданным)Amount > 0 (INV-4); CurrencyId == PurchasePrice.CurrencyId (INV-5)финансовые данные
SoldAtbodyDateTimeOffset?Нет (обязателен при SalePrice)>= PurchasedAt (INV-3), <= now()дата продажи
ReasonbodystringДа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-а:

ЗначениеDealIdExternalOwnershipIdOwnerMappingIdПравило
AdminCorrectionnullnullnullКомпенсирующее ручное создание Службой заботы; идентичность записи — 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/PropertyObjectSnapshotModelREADME.md.

ПолеТипОбязательностьИсточникMaskingОписание
idGuidДаIdId созданной записи.
investorUserIdGuidДаInvestorUserIdВладелец записи.
propertyObjectIdGuidДаPropertyObjectIdОбъект недвижимости (chessboard).
projectIdGuid?НетProjectIdПроект; null — без сопоставления с шахматкой.
buildingIdGuid?НетBuildingIdЗдание/литер.
developerIdGuid?НетDeveloperIdЗастройщик.
statusstring (enum OwnershipStatus)ДаStatusActive / Sold (при вводе проданным).
sourcestring (enum PropertySource)ДаSourceвсегда AdminCorrection.
propertyObjectPropertyObjectSnapshotModelДаPropertyObjectSnapshotСнапшот «что купили».
purchasePriceMoneyModelДаPurchasePriceтолько уполномоченнымЗа сколько купили.
purchasedAtstring (ISO 8601)ДаPurchasedAtКогда купили.
salePriceMoneyModel?Нет (при status = Sold)SalePriceтолько уполномоченнымЗа сколько продали.
soldAtstring (ISO 8601)?Нет (при status = Sold)SoldAtКогда продали.
realizedProfitMoneyModel?Нет (при status = Sold)RealizedProfit (вычислен при продаже, INV-5)только уполномоченнымСколько заработали (< 0 — убыток).
createdByUserIdGuidДаCreatedByUserIdОператор-автор (аудит).
reasonstringДаecho ReasonОбоснование правки.
createdAtstring (ISO 8601)ДаCreatedAtМомент создания.
versionlongДаVersion (= 1 при создании)Доменная версия.

Алгоритм

  1. Контекст и доступ. Операцию выполняет оператор Службы заботы (CreatedByUserId — из claim sub); требуется permission investor-owner.portfolio.manage (иначе 403); scope платформенный.
  2. Проверка входа и предусловий.
    • InvestorUserId/PropertyObjectId != Guid.Empty; PurchasePrice.Amount > 0 (INV-4); PurchasedAt <= now(); Reason непустой; PropertyObjectSnapshot.Title непустой, SnapshotAt задан; связка продажи — SalePriceSoldAt (обе/ни одной), при наличии 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).
  3. Создание. Создаётся запись портфеля: 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 тем же запросом.
  4. События. Публикуется 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}). Создание записи и оба события фиксируются в одном согласованном изменении.
  5. Результат. 201 с моделью InvestorPropertyModel (суммы — только уполномоченным). Конфликт INV-1 → INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE (409).

Диаграмма

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

ТипДетали
Интеграционные событияInvestorPropertyAddedKafkaEventpin.investor-owner.property-added.v1 (всегда); InvestorPropertySoldKafkaEventpin.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
FORBIDDEN403Нет investor-owner.portfolio.manage«Недостаточно прав»no retry
ValidationError400Пустые id; PurchasePrice.Amount <= 0; PurchasedAt > now; неполная/несогласованная пара продажи; пустой ReasonСписок ошибок полейno retry
INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE409Уже есть Active-запись по (InvestorUserId, PropertyObjectId) — INV-1«Активная запись по этому объекту недвижимости уже существует»no retry (сначала архивировать)
INVESTOR_OWNER_PRICE_INVALID422Нарушение INV-4 при продаже (цена <= 0)«Некорректная сумма»no retry
INVESTOR_OWNER_SALE_DATA_INCOMPLETE422Нарушение INV-3 при переводе в Sold (SoldAt < PurchasedAt / неполнота)«Некорректные данные продажи»no retry
INVESTOR_OWNER_CURRENCY_MISMATCH422Нарушение INV-5 (SalePrice.CurrencyId != PurchasePrice.CurrencyId)«Валюты не совпадают»no retry
INVESTOR_OWNER_CURRENCY_UNKNOWN422CurrencyId отсутствует в справочнике валют handbook или валюта архивная«Неизвестная валюта»no retry (исправить CurrencyId)
— (dependency)503Справочник handbook недоступен при резолве валюты«Сервис временно недоступен»повторить позже; портфель не мутирует

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

  • PropertySource.AdminCorrectionadditive значение 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 синхронно не вызываются.

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

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

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