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

GET /api/v1/portfolio/properties — Мои объекты (getMyProperties)

Назначение

Постраничный список объектов портфеля текущего инвестора — основа страницы /profile/my-lots («Мои объекты недвижимости»). Для каждого объекта: снапшот объекта недвижимости, за сколько купили (PurchasePrice, PurchasedAt), за сколько продали и сколько заработали (SalePrice, SoldAt, RealizedProfit — для Sold), текущая оценка и нереализованный доход (read-проекция от invest-analytics — для Active). Дом полей — ../../domain/investor-property.md; общие модели (MoneyModel, PropertyObjectSnapshotModel, CurrentValuationModel) и правило деградации оценки — README.md.

Metadata

ПолеЗначение
Сервис/контекстinvestor-owner
API areapublic
Feature groupportfolio
Статусdraft
Документdocs/06-services/investor-owner/api/public/get-my-properties.md

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

ПроверкаЗначение
Actor typeUser (инвестор, Bearer JWT)
Auth policy / Permissionsauthenticated; permission investor-owner.portfolio.view-self (../../../identity/domain/permission.md)
Scope (RBAC)self (роль не требуется)
Record-levelтолько собственные записи: InvestorUserId = claim sub; условие владельца дублируется явно в запросе (defence-in-depth, README.md)
Tenant isolationне применяется — инвестор вне tenant (ADR-0052)
Auditread-sensitive: финансовые данные отдаются только владельцу; audit-запись не создаётся

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/portfolio/properties
Success status200
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
InvestorUserIdidentity claims (sub)GuidДавалидный JWT (подпись, exp, notBefore) — 401 до операцииrecord-level ключ; в query/route не принимается
StatusqueryOwnershipStatus?Нетenum Active / Sold; неизвестное значение → ValidationError
PagequeryintНет>= 1, default 1
PageSizequeryintНет1..100, default 20 (портфель — единицы-десятки записей, ADR-0056)
Sortquerystring?НетpurchasedAt / createdAt / soldAt (+ префикс - = desc), default -purchasedAt; иное значение → ValidationError

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

ПолеТипОбязательностьИсточникMaskingОписание
itemsIReadOnlyList<MyPropertyListItemModel>ДапроекцияСтраница объектов портфеля.
pageintДаecho запросаТекущая страница.
pageSizeintДаecho запросаРазмер страницы.
totalCountlongДаподсчёт по фильтруВсего записей под фильтром.

MyPropertyListItemModel

ПолеТипОбязательностьИсточникMaskingОписание
idGuidДаInvestorPropertyEntity.IdId записи портфеля (ключ карточки get-my-property.md).
propertyObjectIdGuidДаInvestorPropertyEntity.PropertyObjectIdОбъект недвижимости (chessboard); ключ истории цен invest-analytics.
projectIdGuid?НетInvestorPropertyEntity.ProjectIdПроект; null — объект Talent Owners не сопоставлен с шахматкой PIN.
statusstring (enum OwnershipStatus)ДаInvestorPropertyEntity.StatusActive / Sold.
sourcestring (enum PropertySource)ДаInvestorPropertyEntity.SourcePinCrmDeal / TalentOwnersSync.
propertyObjectPropertyObjectSnapshotModelДаVO PropertyObjectSnapshot (README.md)Снапшот объекта недвижимости на момент покупки.
purchasePriceMoneyModelДаPurchasePriceтолько владельцуЗа сколько купили.
purchasedAtstring (ISO 8601)ДаPurchasedAtКогда купили.
salePriceMoneyModel?Нет (обязателен при status = Sold, INV-3)SalePriceтолько владельцуЗа сколько продали.
soldAtstring (ISO 8601)?Нет (обязателен при status = Sold)SoldAtКогда продали (= когда заработали).
realizedProfitMoneyModel?Нет (обязателен при status = Sold)RealizedProfitтолько владельцуСколько заработали (может быть < 0 — убыток).
currentValuationCurrentValuationModelДаinvest-analytics + computed (README.md)только владельцуТекущая оценка и нереализованный доход; available = false для Sold, projectId = null и при деградации.
createdAtstring (ISO 8601)ДаCreatedAtПоявление записи в портфеле.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (InvestorUserId — из claim sub); требуется permission investor-owner.portfolio.view-self (иначе 403); scope self. Выдаются только собственные записи (record-level InvestorUserId = actor).
  2. Проверка входа. Page >= 1, PageSize ∈ 1..100, Status ∈ enum, Sort ∈ допустимому списку → иначе ValidationError (400). Пустой портфель — валидный ответ с totalCount = 0.
  3. Что читаем. Страница записей портфеля инвестора (не удалённых); при заданном Status — фильтр по статусу; сортировка по Sort. Тем же запросом возвращается totalCount. Затем для активных записей с ProjectId != null — один батч-запрос текущих оценок в invest-analytics (getLotsCurrentPrices, timeout 500 мс, без retry; сбой/таймаут → пустой словарь оценок, правило деградации — README.md). Порядок: сначала страница (определяет PropertyObjectId-ы), затем батч оценок.
  4. Маппинг. Поле-в-поле — таблица MyPropertyListItemModel. currentValuation для каждого item: если оценка получена — available = true, currentPrice = {NewPrice, CurrencyId}, pricedAt = EventTime, unrealizedProfit.amount = currentPrice.amount − PurchasePrice.Amount (только при совпадении валют — currentPrice.currencyId = PurchasePrice.CurrencyId, иначе unrealizedProfit = null); иначе — available = false, остальные поля null. RealizedProfit не пересчитывается — читается как зафиксирован при продаже (INV-5).
  5. Результат. 200. Консистентность: записи портфеля — read-your-writes; currentValuation — eventual (freshness ленты цен invest-analytics ≤ 5 мин + допустимая деградация до available = false).

Диаграмма

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

ТипДетали
Внешние вызовыinvest-analytics getLotsCurrentPrices (батч ≤ PageSize объектов недвижимости, timeout 500 мс, без retry, best-effort — README.md)
Проекции / поискnone (btree-индексы ix_investor_properties_investor_user_id, ix_investor_properties_status, ix_investor_properties_purchased_at; ADR-0056 — без Redis)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен/невалиден JWTбез тела схемы (фронт очищает сессию)re-login
ValidationError400Некорректные Page/PageSize/Status/SortСписок ошибок полейno retry
FORBIDDEN403Нет investor-owner.portfolio.view-self«Недостаточно прав»no retry
— (деградация, не ошибка)200invest-analytics недоступен/timeoutcurrentValuation.available = false у затронутых itemsфронт может перезапросить

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

  • Новые optional поля item-модели — additive; изменение семантики currentValuation или enum-значений — breaking (правило — README.md).
  • Logs: InvestorUserId, фильтры, размер страницы, флаг деградации оценки — без сумм; metrics: investor_owner_my_properties_requests_total{result}, latency histogram, investor_owner_current_valuation_degraded_total; traces: сквозной trace (вложенный span вызова invest-analytics); dashboard investor-owner-overview.

Acceptance Criteria

  • Given инвестор с записями в портфеле, When GET /portfolio/properties без фильтров, Then 200 со страницей -purchasedAt (default Page=1, PageSize=20), totalCount = число не-удалённых записей владельца; в выдаче — только записи с InvestorUserId = sub.
  • Given пустой портфель (или все записи под фильтром отсутствуют), When запрос, Then 200 с items = [], totalCount = 0 (не 404).
  • Given Status = Active, When запрос, Then возвращаются только Active-записи; для каждой с ProjectId != null currentValuation.available = true при полученной оценке, unrealizedProfit.amount = currentPrice − PurchasePrice только при совпадении валют (иначе unrealizedProfit = null).
  • Given Status = Sold, When запрос, Then записи содержат salePrice/soldAt/realizedProfit (INV-3), currentValuation.available = false (для проданных оценка не запрашивается).
  • Given запись Active без сопоставления с шахматкой (projectId = null), When запрос, Then оценка не запрашивается и currentValuation.available = false.
  • Given invest-analytics недоступен/таймаут (500 мс), When запрос, Then 200, у затронутых items currentValuation.available = false, реализованные показатели (realizedProfit) не затронуты, инкремент investor_owner_current_valuation_degraded_total.
  • Given Page < 1, PageSize ∉ 1..100, неизвестный Status или Sort вне списка, When запрос, Then ValidationError (400) без обращения к хранилищу.
  • Given отсутствует investor-owner.portfolio.view-self, When запрос, Then 403; Given просроченный/невалидный JWT, Then 401 до операции.

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

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

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