# Portfolio List Enrichment **Date:** 2026-06-14 **Status:** Draft ## Problem `GET /api/v1/portfolios` возвращает сырые записи из БД без какой-либо обогащённой информации. На фронтенде карточка портфеля показывает только название, описание, валюту и дату обновления. Пользователь не видит общую стоимость портфеля, количество позиций и распределение по типам без перехода на страницу деталей. ## Solution Обогатить `GET /api/v1/portfolios` данными из MOEX, используя тот же batch-подход, что и в `enrichPositions` для детального эндпоинта. ### Backend #### Новый DTO: `PortfolioListResponseDto` ```typescript class PortfolioListResponseDto extends PortfolioResponseDto { totalValue: number; positionCount: number; shareCount: number; bondCount: number; } ``` #### Изменение `PortfolioService.findAll(userId)` Текущий код: ```typescript async findAll(userId: number) { return this.prisma.portfolio.findMany({ where: { userId }, orderBy: { updatedAt: 'desc' }, }); } ``` Новый код: ```typescript async findAll(userId: number) { const portfolios = await this.prisma.portfolio.findMany({ where: { userId }, include: { positions: true }, orderBy: { updatedAt: 'desc' }, }); // Собрать все unique secid из всех портфелей const allPositions = portfolios.flatMap((p) => p.positions); if (allPositions.length === 0) { return portfolios.map((p) => ({ id: p.id, name: p.name, description: p.description, currency: p.currency, createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), totalValue: 0, positionCount: 0, shareCount: 0, bondCount: 0, })); } // Один batch-запрос к MOEX для всех secid разом const enrichedPositions = await this.enrichPositions(allPositions); const posByPortfolioId = new Map(); for (const pos of enrichedPositions) { const pfId = allPositions.find((ap) => ap.id === pos.id)!.portfolioId; if (!posByPortfolioId.has(pfId)) posByPortfolioId.set(pfId, []); posByPortfolioId.get(pfId)!.push(pos); } return portfolios.map((p) => { const positions = posByPortfolioId.get(p.id) ?? []; const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0); return { id: p.id, name: p.name, description: p.description, currency: p.currency, createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), totalValue: Math.round(totalValue * 100) / 100, positionCount: positions.length, shareCount: positions.filter((pos) => pos.type === 'share').length, bondCount: positions.filter((pos) => pos.type === 'bond').length, }; }); } ``` #### Изменение `PortfolioController.findAll` Ответ типизируется как `PortfolioListResponseDto[]`. ### Frontend #### Тип `Portfolio` в `responses.ts` — добавить поля ```typescript export interface Portfolio { id: number; name: string; description: string | null; currency: string; createdAt: string; updatedAt: string; totalValue: number; positionCount: number; shareCount: number; bondCount: number; } ``` #### `PortfolioCard.tsx` — расширить Показывать: 1. Название (слева) + общая стоимость (справа, крупно, с валютой) 2. Описание (если есть) 3. Чипсы: `N акций`, `M облигаций`, `K позиций` (тёмный фон, белый текст) 4. Дата обновления #### `PortfoliosListPage.tsx` — без изменений ### Data Flow ``` GET /api/v1/portfolios → PortfolioService.findAll(userId) → prisma.portfolio.findMany({ include: { positions: true } }) → Collect all unique secids across all portfolios → enrichPositions(allPositions) // ONE batch MOEX call (cached) → fetchShareBatch(shareSecids) // batched → fetchBondBatch(bondSecids) // batched → Compute aggregated fields per portfolio → Return PortfolioListResponseDto[] ``` Кеширование: batch-запросы к MOEX уже кешируются через `CacheService` с `marketDataTtl` (900s). При повторном запросе в течение 15 минут ответ будет из кеша. ### Risks and Edge Cases | Risk | Mitigation | |---|---| | **Позиций нет ни в одном портфеле** | Early return без вызова MOEX | | **MOEX недоступен** | enrichPositions уже обрабатывает `null` данные — totalValue будет 0, чипсы покажут только количество | | **Много портфелей с сотнями позиций** | Те же batch-запросы, что и у findOne — не более 2 HTTP-вызовов | | **Кеш прогрет от findOne** | List получит данные мгновенно | | **Пустой portfolioId Map** | positions = [] → totalValue = 0, positionCount = 0 |