151 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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` — добавить поля
```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 |