docs: add spec and plan for portfolio list enrichment
All checks were successful
All checks were successful
This commit is contained in:
parent
2d2089915d
commit
1017fd4f08
613
docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md
Normal file
613
docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md
Normal file
@ -0,0 +1,613 @@
|
|||||||
|
# Portfolio List Enrichment — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Enrich `GET /api/v1/portfolios` with totalValue, positionCount, shareCount, bondCount from MOEX batch data and display on PortfolioCard.
|
||||||
|
|
||||||
|
**Architecture:** Backend collects all positions across user's portfolios, does ONE batch MOEX call (cached), computes aggregates per portfolio. Frontend displays new fields on the existing card component.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, Prisma, MoexClientService (batch), React, TanStack Query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create PortfolioListResponseDto
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create DTO file**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { PortfolioResponseDto } from './portfolio-response.dto';
|
||||||
|
|
||||||
|
export class PortfolioListResponseDto extends PortfolioResponseDto {
|
||||||
|
@ApiProperty({ description: 'Total market value of all positions' })
|
||||||
|
totalValue!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total number of positions' })
|
||||||
|
positionCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Number of share positions' })
|
||||||
|
shareCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Number of bond positions' })
|
||||||
|
bondCount!: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit -w apps/backend`
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts
|
||||||
|
git commit -m "feat(backend): add PortfolioListResponseDto"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Write failing tests for PortfolioService.findAll enrichment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create test file with failing tests**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
import configuration from '../../config/configuration';
|
||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('PortfolioService', () => {
|
||||||
|
let service: PortfolioService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let moexClient: MoexClientService;
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'Test Portfolio',
|
||||||
|
description: 'A test portfolio',
|
||||||
|
currency: 'RUB',
|
||||||
|
targets: null,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
updatedAt: new Date('2026-06-14'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockPosition = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
portfolioId: 1,
|
||||||
|
secid: 'SBER',
|
||||||
|
type: 'share',
|
||||||
|
quantity: 10,
|
||||||
|
notes: null,
|
||||||
|
tags: null,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
updatedAt: new Date('2026-06-14'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||||
|
providers: [
|
||||||
|
PortfolioService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
portfolio: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MoexClientService,
|
||||||
|
useValue: {
|
||||||
|
getShareMarketDataBatch: vi.fn(),
|
||||||
|
getBondPositionDataBatch: vi.fn(),
|
||||||
|
getSecurityDescription: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CacheService,
|
||||||
|
useValue: {
|
||||||
|
getOrFetch: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<PortfolioService>(PortfolioService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||||
|
// CacheService is a useValue mock object
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return empty array when user has no portfolios', async () => {
|
||||||
|
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
|
||||||
|
const result = await service.findAll(1);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return portfolios with zero aggregates when no positions exist', async () => {
|
||||||
|
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||||
|
mockPortfolio({ positions: [] }) as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.findAll(1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
name: 'Test Portfolio',
|
||||||
|
totalValue: 0,
|
||||||
|
positionCount: 0,
|
||||||
|
shareCount: 0,
|
||||||
|
bondCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enrich portfolios with market data from batch MOEX call', async () => {
|
||||||
|
const sharePosition = mockPosition({
|
||||||
|
id: 1,
|
||||||
|
secid: 'SBER',
|
||||||
|
type: 'share',
|
||||||
|
quantity: 10,
|
||||||
|
});
|
||||||
|
const bondPosition = mockPosition({
|
||||||
|
id: 2,
|
||||||
|
portfolioId: 1,
|
||||||
|
secid: 'SU26238RMFS5',
|
||||||
|
type: 'bond',
|
||||||
|
quantity: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||||
|
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||||
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||||
|
{
|
||||||
|
secid: 'SU26238RMFS5',
|
||||||
|
shortName: 'OFZ 26238',
|
||||||
|
price: 98.5,
|
||||||
|
faceValue: 1000,
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||||
|
cacheMock.getOrFetch.mockImplementation(
|
||||||
|
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||||
|
data: await fetchFn(),
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.findAll(1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('Test Portfolio');
|
||||||
|
expect(result[0].positionCount).toBe(2);
|
||||||
|
expect(result[0].shareCount).toBe(1);
|
||||||
|
expect(result[0].bondCount).toBe(1);
|
||||||
|
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
|
||||||
|
expect(result[0].totalValue).toBe(7425);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate MOEX errors to the caller', async () => {
|
||||||
|
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||||
|
mockPortfolio({ positions: [mockPosition()] }) as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||||
|
cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down'));
|
||||||
|
|
||||||
|
await expect(service.findAll(1)).rejects.toThrow('MOEX down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return portfolios belonging to the requesting user', async () => {
|
||||||
|
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.findAll(2);
|
||||||
|
|
||||||
|
expect(prisma.portfolio.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 2 },
|
||||||
|
include: { positions: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should throw NotFoundException for non-existent portfolio', async () => {
|
||||||
|
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
||||||
|
await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException for wrong user', async () => {
|
||||||
|
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||||
|
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||||
|
Expected: FAIL — tests assert behavior that's not yet implemented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Implement backend enrichment in PortfolioService.findAll
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` — rewrite `findAll` method
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the findAll method**
|
||||||
|
|
||||||
|
Current code (lines 62-67):
|
||||||
|
```typescript
|
||||||
|
async findAll(userId: number) {
|
||||||
|
return this.prisma.portfolio.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```typescript
|
||||||
|
async findAll(userId: number) {
|
||||||
|
const portfolios = await this.prisma.portfolio.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { positions: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedPositions = await this.enrichPositions(allPositions);
|
||||||
|
|
||||||
|
const posByPortfolioId = new Map<number, (typeof enrichedPositions)[number][]>();
|
||||||
|
for (let i = 0; i < enrichedPositions.length; i++) {
|
||||||
|
const pfId = allPositions[i].portfolioId;
|
||||||
|
if (!posByPortfolioId.has(pfId)) {
|
||||||
|
posByPortfolioId.set(pfId, []);
|
||||||
|
}
|
||||||
|
posByPortfolioId.get(pfId)!.push(enrichedPositions[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/modules/portfolio/portfolio.service.ts \
|
||||||
|
apps/backend/src/modules/portfolio/portfolio.service.spec.ts
|
||||||
|
git commit -m "feat(backend): enrich portfolio list with MOEX batch data"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update PortfolioController with new DTO
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/backend/src/modules/portfolio/portfolio.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import PortfolioListResponseDto**
|
||||||
|
|
||||||
|
Add import at top:
|
||||||
|
```typescript
|
||||||
|
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update findAll to use new DTO in Swagger**
|
||||||
|
|
||||||
|
Replace method with ApiResponse decorator:
|
||||||
|
```typescript
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||||
|
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
|
||||||
|
async findAll(@CurrentUser() user: { sub: number }) {
|
||||||
|
const portfolios = await this.portfolioService.findAll(user.sub);
|
||||||
|
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add the import:
|
||||||
|
```typescript
|
||||||
|
import { ApiOkResponse } from '@nestjs/swagger';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run existing tests to verify no regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
|
||||||
|
git commit -m "feat(backend): add Swagger decorators for enriched portfolio list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update frontend types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/api/responses.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add new fields to Portfolio interface**
|
||||||
|
|
||||||
|
Current (lines 138-145):
|
||||||
|
```typescript
|
||||||
|
export interface Portfolio {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
currency: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `npx tsc -b apps/frontend`
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/api/responses.ts
|
||||||
|
git commit -m "feat(frontend): add enrichment fields to Portfolio type"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update PortfolioCard to show enriched data
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/components/portfolios/PortfolioCard.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace PortfolioCard implementation**
|
||||||
|
|
||||||
|
Current (lines 1-39):
|
||||||
|
```typescript
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { Portfolio } from '../../api/responses';
|
||||||
|
|
||||||
|
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/portfolios/${portfolio.id}`}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: 20,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
|
||||||
|
{portfolio.description && (
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{portfolio.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
marginTop: 8,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```typescript
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { Portfolio } from '../../api/responses';
|
||||||
|
|
||||||
|
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||||
|
const chipStyle = (bg: string): React.CSSProperties => ({
|
||||||
|
background: bg,
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/portfolios/${portfolio.id}`}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: 20,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{portfolio.currency}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{portfolio.description && (
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{portfolio.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
{portfolio.shareCount > 0 && (
|
||||||
|
<span style={chipStyle('#1b5e20')}>
|
||||||
|
{portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{portfolio.bondCount > 0 && (
|
||||||
|
<span style={chipStyle('#0d47a1')}>
|
||||||
|
{portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={chipStyle('#424242')}>
|
||||||
|
{portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||||
|
обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(n: number, one: string, few: string, many: string): string {
|
||||||
|
if (n % 10 === 1 && n % 100 !== 11) return one;
|
||||||
|
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify frontend builds**
|
||||||
|
|
||||||
|
Run: `npm run build:frontend -w apps/frontend` (or `npx tsc -b apps/frontend`)
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/components/portfolios/PortfolioCard.tsx
|
||||||
|
git commit -m "feat(frontend): display enriched data in PortfolioCard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Run full test suite and verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run backend tests**
|
||||||
|
|
||||||
|
Run: `npm run test:backend`
|
||||||
|
Expected: All tests pass (including new portfolio service tests)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run frontend build**
|
||||||
|
|
||||||
|
Run: `npm run build:frontend`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run linter**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: No lint errors
|
||||||
150
docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md
Normal file
150
docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# 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 |
|
||||||
Loading…
x
Reference in New Issue
Block a user