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

GET /api/v1/portfolio/summary — Сводка доходности портфеля (getMyPortfolioSummary)

Назначение

Сводка доходности портфеля текущего инвестора для шапки страницы /profile/my-lots: суммарно — сколько всего вложено, за сколько продано, сколько заработано (Σ RealizedProfit по Sold), текущая стоимость активной части и нереализованный доход; по объекту — компактная разбивка «за сколько купили / за сколько продали / сколько и когда заработали» по каждой записи. Дом полей — ../../domain/investor-property.md. Общие модели (MoneyModel, CurrentValuationModel) и правило деградации оценки — README.md.

Metadata

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

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

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

HTTP-контракт

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

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

Body/route/query-параметров нет.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
InvestorUserIdidentity claims (sub)GuidДавалидный JWT (подпись, exp, notBefore) — 401 до операцииrecord-level ключ

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

ПолеТипОбязательностьИсточникMaskingОписание
totalCountintДаcomputed: число не-удалённых записейВсего объектов в портфеле.
activeCountintДаcomputed: Count(Status = Active)Объектов в собственности.
soldCountintДаcomputed: Count(Status = Sold)Проданных объектов.
totalsIReadOnlyList<PortfolioCurrencyTotalsModel>Даcomputed (группировка по PurchasePrice.CurrencyId)только владельцуСуммарные показатели; v1 — один элемент (RUB, ADR-0048), контейнер — задел мультивалютности. Пустой портфель → пустой список.
itemsIReadOnlyList<PortfolioPropertyProfitModel>Дапроекция + computedтолько владельцуРазбивка доходности «по объекту» (весь портфель без пагинации — единицы-десятки записей, ADR-0056), сортировка -purchasedAt.

PortfolioCurrencyTotalsModel (суммы — по записям c данным PurchasePrice.CurrencyId)

ПолеТипОбязательностьИсточникMaskingОписание
currencyIdshortДаключ группировки — ссылка на справочник валют handbook (../../../handbook/domain/currency.md)Валюта всех сумм модели (типизированная ссылка).
currencyIsoCodestringДаденормализованный CurrencyIsoCode записей группы (3 символа, ISO-4217; снят при записи)Display-код валюты группы — для подписи сумм без резолва справочника.
investedTotalMoneyModelДаcomputed: Σ PurchasePrice по всем записям группы (Active + Sold)только владельцуСколько всего вложено за время работы с PIN.
activeInvestedTotalMoneyModelДаcomputed: Σ PurchasePrice по Activeтолько владельцуВложено в объекты, которыми владеет сейчас.
soldAmountTotalMoneyModelДаcomputed: Σ SalePrice по Soldтолько владельцуЗа сколько суммарно продали.
realizedProfitTotalMoneyModelДаcomputed: Σ RealizedProfit по Sold (INV-3 гарантирует заполненность; может быть < 0)только владельцуСколько всего заработали (реализованный доход).
currentValueTotalMoneyModel?Нетcomputed: Σ currentPrice по Active c полученной оценкой; null, если оценённых объектов неттолько владельцуТекущая стоимость оценённой активной части.
unrealizedProfitTotalMoneyModel?Нетcomputed: Σ (currentPrice − PurchasePrice) по тем же записям; null вместе с currentValueTotalтолько владельцуНереализованный доход (не хранится — read-проекция).
pricedActiveCountintДаcomputedСколько Active-объектов вошло в currentValueTotal (для честной подписи «оценено N из M» при деградации/несопоставленных объектах).

PortfolioPropertyProfitModel (дельта от get-my-properties.md: без снапшота объекта недвижимости целиком)

ПолеТипОбязательностьИсточникMaskingОписание
idGuidДаInvestorPropertyEntity.IdКлюч карточки get-my-property.md.
propertyObjectIdGuidДаPropertyObjectIdОбъект недвижимости.
titlestringДаPropertyObjectSnapshot.TitleНазвание объекта.
projectNamestring?НетPropertyObjectSnapshot.Project?.ProjectNameНазвание проекта (компактная разбивка; id проекта — в карточке).
statusstring (enum OwnershipStatus)ДаStatusActive / Sold.
purchasePriceMoneyModelДаPurchasePriceтолько владельцуЗа сколько купили.
purchasedAtstring (ISO 8601)ДаPurchasedAtКогда купили.
salePriceMoneyModel?Нет (обязателен при status = Sold)SalePriceтолько владельцуЗа сколько продали.
soldAtstring (ISO 8601)?Нет (обязателен при status = Sold)SoldAtКогда заработали.
realizedProfitMoneyModel?Нет (обязателен при status = Sold)RealizedProfitтолько владельцуСколько заработали по объекту.
currentValuationCurrentValuationModelДаinvest-analytics + computed (README.md)только владельцуТекущая оценка/нереализованный доход (available = false для Sold / несопоставленных / деградации).

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (InvestorUserId — из claim sub); требуется permission investor-owner.portfolio.view-self (иначе 403); scope self. Агрегируются только собственные записи.
  2. Проверка входа. Входных полей формы нет. Пустой портфель — валидный ответ: нули в counts, пустые totals/items.
  3. Что читаем. Весь портфель актора (не удалённые записи), сортировка -PurchasedAt; без пагинации осознанно (единицы-десятки записей, ADR-0056), защитный предел выборки 1000 записей (превышение — инфраструктурная аномалия, алерт). Затем для активных записей с ProjectId != null — один батч-запрос текущих оценок в invest-analytics (getLotsCurrentPrices, timeout 500 мс, без retry; сбой → пустой словарь, правило деградации — README.md). Порядок: сначала весь портфель (определяет объекты для оценки), затем батч оценок.
  4. Расчёты. (decimal, без округления — суммы уже numeric(18,2)):
    • items[i].currentValuation — по правилу README.md (как в списке);
    • группировка записей по PurchasePrice.CurrencyId → для каждой группы PortfolioCurrencyTotalsModel (currencyId — ключ; currencyIsoCode — денормализованный display-код записей группы): investedTotal = Σ PurchasePrice.Amount; activeInvestedTotal = Σ по Status = Active; soldAmountTotal = Σ SalePrice.Amount по Status = Sold; realizedProfitTotal = Σ RealizedProfit.Amount по Sold (значения читаются зафиксированными при продаже — INV-3/INV-5, не пересчитываются); currentValueTotal = Σ currentPrice по Active c оценкой в валюте группы (currentPrice.currencyId = currencyId группы); unrealizedProfitTotal = currentValueTotal − Σ PurchasePrice.Amount по тем же записям; pricedActiveCount = число таких записей; оценка в валюте, отличной от валюты покупки (CurrencyId не совпадает), в суммы группы не включается (запись остаётся в activeCount, но не в pricedActiveCount);
    • counts: totalCount = число записей; activeCount/soldCount — по Status.
  5. Результат. 200. Консистентность: записи портфеля — read-your-writes; currentValueTotal/unrealizedProfitTotal — eventual (freshness ленты цен ≤ 5 мин; при деградации — null + pricedActiveCount показывает покрытие).

Диаграмма

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

ТипДетали
Внешние вызовыinvest-analytics getLotsCurrentPrices (один батч по активным сопоставленным объектам недвижимости, timeout 500 мс, без retry, best-effort)
Проекции / поискnone: агрегация on-read по ix_investor_properties_investor_user_id (ADR-0056 — без Redis и материализованных сводок; предвычисление появится только при доказанной деградации latency)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен/невалиден JWTбез тела схемыre-login
FORBIDDEN403Нет investor-owner.portfolio.view-self«Недостаточно прав»no retry
— (деградация, не ошибка)200invest-analytics недоступен/timeoutcurrentValueTotal = null, unrealizedProfitTotal = null, pricedActiveCount = 0; реализованные показатели полныфронт может перезапросить

Кода 404 у операции нет: пустой портфель — валидный 200 c нулями/пустыми списками.

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

  • Новые optional поля — additive; изменение формулы realizedProfitTotal/unrealizedProfitTotal — breaking для фронта (согласование с продуктом + rollout); появление второй валюты не ломает контракт (totals — список).
  • Logs: InvestorUserId, counts, флаг деградации — без сумм; metrics: investor_owner_portfolio_summary_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/summary, Then 200 с корректными counts (totalCount/activeCount/soldCount), одним элементом totals (currencyId валюты RUB, currencyIsoCode = "RUB"): investedTotal = Σ PurchasePrice (Active+Sold), realizedProfitTotal = Σ RealizedProfit по Sold, и items по всему портфелю (-purchasedAt, без пагинации).
  • Given пустой портфель, When запрос, Then 200 с нулями в counts и пустыми totals/items (кода 404 нет).
  • Given часть Active-записей оценена, часть — нет (деградация/несопоставленные), When запрос, Then currentValueTotal/unrealizedProfitTotal считаются только по оценённым, pricedActiveCount отражает покрытие «оценено N из M».
  • Given полная недоступность invest-analytics, When запрос, Then 200, currentValueTotal = null, unrealizedProfitTotal = null, pricedActiveCount = 0; реализованные показатели (investedTotal/soldAmountTotal/realizedProfitTotal) полны и не затронуты.
  • Given активная запись с оценкой в валюте, отличной от валюты покупки (currencyId оценки ≠ CurrencyId записи), When запрос, Then запись учтена в activeCount, но не в pricedActiveCount и не в суммах группы.
  • Given нет investor-owner.portfolio.view-self, When запрос, Then 403; невалидный JWT — 401.

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

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

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