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

UC-OWN-003. Инвестор просматривает портфель «Мои объекты недвижимости» и доходность

Назначение

Описывает пользовательский сценарий просмотра портфеля: инвестор открывает раздел «Мои объекты недвижимости» (/profile/my-lots) и видит список своих объектов и сводку доходности — за сколько купил и продал, сколько и когда заработал. Данные читаются только по владельцу (record-level по InvestorUserId); операция ничего не меняет. Связан с контекстом investor-owner.

Metadata

ПолеЗначение
IDUC-OWN-003
Контекстinvestor-owner (см. docs/06-services/investor-owner/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (авторизованный UserEntity.Type = Investor)
Permissions / scopeinvestor-owner.portfolio.view-self (self-scope); Служба заботы / Менеджер ПИН читают чужой портфель отдельным admin-API под investor-owner.portfolio.view (платформенный scope, с аудитом доступа) — не этот UC
TenantTenantId = null — инвестор вне tenant (ADR-0052)
Record-levelобязательный фильтр InvestorUserId = actor.UserId (record-level изоляция; тесты изоляции обязательны)

Предусловие

  • Инвестор авторизован (access-JWT identity), Type = Investor.
  • В портфеле есть записи InvestorPropertyEntity (из UC-OWN-001 или UC-OWN-002); пустой портфель — валидный кейс (A1).
  • Сервис investor-owner доступен; invest-analytics/catalog — опциональные зависимости текущей оценки (graceful degradation — A2).

Основной Поток

  1. Инвестор открывает раздел «Мои объекты недвижимости»; интерфейс параллельно запрашивает список и сводку.
  2. Интерфейс вызывает GET /api/v1/investor-owner/my/properties?status=Active&page=1&pageSize=20 (api-метод ../api/public/get-my-properties.md, будущий путь) и GET /api/v1/investor-owner/my/portfolio-summary (../api/public/get-my-portfolio-summary.md, будущий путь) с access-JWT.
  3. Система проверяет JWT, определяет актора (инвестор, UserId) и его право investor-owner.portfolio.view-self.
  4. Список «Мои объекты недвижимости»:
    1. Проверка входа: Page >= 1, PageSize ∈ [1..100], Status — валидное значение OwnershipStatus или null (все статусы); при нарушении — ValidationError.
    2. Читаем страницу записей портфеля владельца (record-level фильтр InvestorUserId = actor.UserId, только не удалённые; при заданном Status — с фильтром по статусу), сортировка PurchasedAt убыв. с детерминированным tiebreak по Id (стабильная пагинация при равных датах покупки). Страница за пределами диапазона (Page больше числа страниц) → пустой Items при корректном TotalCount, HTTP 200.
    3. Маппинг элемента списка InvestorPropertyListItemModel (дельта от дома полей ../domain/investor-property.md): Id; PropertyObjectId; ProjectId; Title ← PropertyObjectSnapshot.Title; Number ← PropertyObjectSnapshot.Number; ProjectName ← PropertyObjectSnapshot.Project?.ProjectName; AreaTotal ← PropertyObjectSnapshot.AreaTotal; Floor ← PropertyObjectSnapshot.Floor; Status; Source; PurchasePrice {Amount, CurrencyId}; PurchasedAt; SalePrice; SoldAt; RealizedProfit (для Sold); HasPriceHistory ← (ProjectId != null) (объект сопоставлен с шахматкой PIN — история цен доступна, см. UC-OWN-004 A2).
    4. Результат: контейнер { Items: InvestorPropertyListItemModel[], Page, PageSize, TotalCount }; суммы отдаются только владельцу (record-level гарантирован) и не пишутся в логи.
  5. Параллельно — сводка портфеля PortfolioSummaryModel:
    1. Читаем агрегат по записям владельца (InvestorUserId = actor.UserId, только не удалённые; портфель — единицы-десятки строк, без кэшей и материализаций, ADR-0056).
    2. Расчёт:
      • TotalInvested = Σ PurchasePrice.Amount по всем записям (валюта по умолчанию — RUB; при разных валютах суммы группируются по CurrencyId в TotalsByCurrency[] — ключ CurrencyId + display CurrencyIsoCode, см. ../api/public/get-my-portfolio-summary.md);
      • TotalEarned = Σ RealizedProfit.Amount по записям Status = Sold («сколько заработали»);
      • LastEarnedAt = max(SoldAt) по Sold («когда заработали»; null, если продаж нет);
      • ActiveCount = число записей Status = Active; SoldCount = число Status = Sold;
      • TotalSalesAmount = Σ SalePrice.Amount по Sold («за сколько продали», сводно).
    3. Результат: PortfolioSummaryModel.
  6. Опционально (текущая оценка активных объектов): интерфейс батч-запросом получает текущие цены объектов недвижимости из invest-analytics по списку PropertyObjectId активных записей (будущий путь ../../invest-analytics/api/get-lot-current-prices.md); нереализованный доход UnrealizedProfit = CurrentPrice − PurchasePrice.Amount вычисляется на чтение и НЕ хранится в investor-owner (README «НЕ делает»).
  7. Интерфейс рендерит: табы Active/Sold, карточки с ценой покупки/продажи и доходом, сводку («всего вложено / всего заработано / активных / продано»); переключение таба → повторный запрос шага 2 с другим status.
  8. Клик по объекту открывает карточку — UC-OWN-004.

Альтернативные Потоки

A1. Пустой портфель

  1. Выборка возвращает TotalCount = 0; сводка — нули (TotalInvested = 0, TotalEarned = 0).
  2. Интерфейс показывает empty-state «У вас пока нет объектов». Сценарий завершается успешно.

A2. invest-analytics/catalog недоступны (текущая оценка)

  1. Шаг 6 завершается ошибкой/таймаутом → интерфейс показывает портфель без текущей оценки и без UnrealizedProfit (graceful degradation, README «Зависимости»); цены покупки/продажи и RealizedProfit — из investor-owner, не затронуты.
  2. Возврат к основному потоку при следующем обновлении страницы.

A3. Фильтр по проданным («сколько и когда заработали»)

  1. Инвестор переключает таб «Проданные» → запрос status=Sold.
  2. Список показывает по каждой записи SalePrice, SoldAt, RealizedProfit (зафиксированы при продаже, INV-3 SoldCompleteness); сортировка — SoldAt desc (параметр orderBy=soldAt).

A4. Пользователь не инвестор

  1. Авторизованный пользователь с Type != Investor (агент/менеджер) вызывает API → отсутствует право investor-owner.portfolio.view-selfFORBIDDEN → HTTP 403.
  2. Интерфейс не показывает раздел «Мои объекты недвижимости» таким ролям (защита и на UI, и на API).

Постусловие

  • Состояние системы не изменилось (read-only: ни мутаций, ни доменных, ни интеграционных событий).
  • Инвестор увидел только собственные записи (record-level InvestorUserId = actor); чужие записи недоступны ни при каких параметрах запроса.
  • Показатели консистентны на момент чтения одной БД-выборки (без кэшей — свежесть равна PostgreSQL).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
UNAUTHORIZED401Отсутствует/просрочен access-JWTrefresh-flow identity; повтор запроса
FORBIDDEN403Нет permission investor-owner.portfolio.view-self (A4)скрыть раздел, показать «недоступно»
ValidationError400Page < 1, PageSize > 100, неизвестный statusисправить параметры запроса
INTERNAL_ERROR500Инфраструктурный сбой (PostgreSQL down)retry с backoff; error-state страницы

Acceptance Criteria

#КритерийПроверка
AC1Инвестор видит только свои записи; запрос под другим инвестором не возвращает чужиетест изоляции record-level (обязателен, README «Безопасность»)
AC2Список содержит цену покупки/дату, для Sold — цену продажи, дату и RealizedProfitконтракт-тест ответа
AC3Сводка: TotalEarned = Σ RealizedProfit (Sold), TotalInvested = Σ PurchasePrice, счётчики вернытест агрегата на фикстуре из 3 Active + 2 Sold
AC4Фильтр status и пагинация работают, сортировка PurchasedAt desc по умолчаниютест выборки
AC5Пустой портфель → TotalCount = 0, нулевая сводка, HTTP 200 (A1)тест пустого состояния
AC6Недоступность invest-analytics не ломает страницу — портфель отдаётся без текущей оценки (A2)тест деградации
AC7Не-инвестор получает 403 (A4)тест авторизации
AC8Суммы не пишутся в логи открытым текстомпроверка логов

Наблюдаемость и НФТ

  • Латентность: p99 API портфеля и сводки < 300 мс (README «Runtime И Эксплуатация»); нагрузка — единицы-десятки записей на инвестора, без кэшей и материализаций (ADR-0056).
  • Логирование операции — без финансовых сумм и PII открытым текстом (AC8): фиксируется факт доступа актора к своему портфелю (актор, TotalCount, статус-фильтр), но не значения цен/дохода.
  • Обязательны тесты record-level изоляции (AC1): выборка под другим инвестором не возвращает чужие записи ни при каких значениях Status/Page/PageSize.

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

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

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