5.4 KiB
Raw Blame History

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 — расширить

Показывать:

  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