151 lines
5.4 KiB
Markdown
151 lines
5.4 KiB
Markdown
# 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 |
|