5.4 KiB
5.4 KiB
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
class PortfolioListResponseDto extends PortfolioResponseDto {
totalValue: number;
positionCount: number;
shareCount: number;
bondCount: number;
}
Изменение PortfolioService.findAll(userId)
Текущий код:
async findAll(userId: number) {
return this.prisma.portfolio.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
});
}
Новый код:
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<number, EnrichedPosition[]>();
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 — добавить поля
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 — расширить
Показывать:
- Название (слева) + общая стоимость (справа, крупно, с валютой)
- Описание (если есть)
- Чипсы:
N акций,M облигаций,K позиций(тёмный фон, белый текст) - Дата обновления
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 |