653 lines
19 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.

# Аналитика прибыльности брокерского счёта — Implementation Plan
> **For agentic workers:** Use subagent-driven-development or executing-plans to implement this plan task-by-task.
**Goal:** Add an analytics tab to the broker account page showing net invested vs received (dividends + coupons).
**Architecture:** New backend service aggregates BrokerOperation records by type via Prisma, returns DTO. New frontend tab page displays invested/received blocks.
**Tech Stack:** NestJS + Prisma (SQLite), React + TanStack Query + react-router
---
## File Structure
### Backend (new/modified)
- Create: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts`
- Create: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts`
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` — add `GET /analytics` endpoint
- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` — register service
### Frontend (new/modified)
- Create: `apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts`
- Create: `apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts`
- Create: `apps/frontend/src/entities/broker-analytics/index.ts`
- Create: `apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx`
- Create: `apps/frontend/src/pages/broker-analytics/index.ts`
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
- Modify: `apps/frontend/src/app/routing/routeTree.tsx`
- Modify: `apps/frontend/src/shared/api/types.ts`
---
### Task 1: Backend DTO и сервис аналитики
**Files:**
- Create: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts`
- Create: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts`
#### Шаг 1.1: Создать DTO
```typescript
import { ApiProperty } from '@nestjs/swagger';
export class BrokerAnalyticsDto {
@ApiProperty()
totalDeposits!: number;
@ApiProperty()
totalWithdrawn!: number;
@ApiProperty()
netInvested!: number;
@ApiProperty()
totalDividends!: number;
@ApiProperty()
totalCoupons!: number;
@ApiProperty()
totalReceived!: number;
@ApiProperty({ type: Number, nullable: true })
totalReturnPercent!: number | null;
@ApiProperty()
currency!: string;
}
```
#### Шаг 1.2: Создать сервис `BrokerAnalyticsService`
```typescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CacheService } from '../../cache/cache.service';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
const DEPOSIT_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_INP_MULTI',
'OPERATION_TYPE_OVER_PLACEMENT',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
]);
const WITHDRAWAL_TYPES = new Set([
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_OUT_MULTI',
]);
const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']);
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
const ANALYTICS_TYPES = new Set([
...DEPOSIT_TYPES,
...WITHDRAWAL_TYPES,
...DIVIDEND_TYPES,
...COUPON_TYPES,
]);
@Injectable()
export class BrokerAnalyticsService {
constructor(
private readonly prisma: PrismaService,
private readonly accountsService: BrokerAccountsService,
private readonly cacheService: CacheService,
) {}
async getAnalytics(accountId: string): Promise<{
data: BrokerAnalyticsDto;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
return this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.analytics,
[accountId],
() => this.computeAnalytics(accountId),
'tbankAnalyticsTtl',
);
}
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
const operations = await this.prisma.brokerOperation.findMany({
where: {
accountId,
type: { in: Array.from(ANALYTICS_TYPES) },
payment: { not: null },
},
select: { type: true, payment: true },
});
let totalDeposits = 0;
let totalWithdrawn = 0;
let totalDividends = 0;
let totalCoupons = 0;
for (const op of operations) {
const payment = JSON.parse(op.payment!);
const value = payment.value ?? 0;
if (DEPOSIT_TYPES.has(op.type)) {
totalDeposits += value;
} else if (WITHDRAWAL_TYPES.has(op.type)) {
totalWithdrawn += Math.abs(value);
} else if (DIVIDEND_TYPES.has(op.type)) {
totalDividends += value;
} else if (COUPON_TYPES.has(op.type)) {
totalCoupons += value;
}
}
const netInvested = totalDeposits - totalWithdrawn;
const totalReceived = totalDividends + totalCoupons;
const totalReturnPercent =
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
return {
totalDeposits: Math.round(totalDeposits * 100) / 100,
totalWithdrawn: Math.round(totalWithdrawn * 100) / 100,
netInvested: Math.round(netInvested * 100) / 100,
totalDividends: Math.round(totalDividends * 100) / 100,
totalCoupons: Math.round(totalCoupons * 100) / 100,
totalReceived: Math.round(totalReceived * 100) / 100,
totalReturnPercent,
currency: 'RUB',
};
}
}
```
#### Шаг 1.3: Проверить сборку
```bash
npm run build -w apps/backend
```
#### Шаг 1.4: Закоммитить
```bash
git add apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts
git add apps/backend/src/modules/tbank/services/broker-analytics.service.ts
git commit -m "feat(backend): add broker analytics DTO and service"
```
---
### Task 2: Backend controller, module registration и конфиг кеша
**Files:**
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts`
- Modify: `apps/backend/src/modules/tbank/tbank.module.ts`
- Modify: `apps/backend/src/modules/tbank/tbank.config.ts`
- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`
- Modify: `apps/backend/src/config/configuration.ts`
#### Шаг 2.1: Добавить cache key
В `apps/backend/src/modules/tbank/tbank.config.ts`:
```typescript
export const TBANK_CACHE_KEYS = {
// ... existing keys
analytics: 'tbank:analytics',
} as const;
```
#### Шаг 2.2: Добавить TTL config
В `apps/backend/src/config/configuration.ts`, в секцию `cache`:
```typescript
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
```
#### Шаг 2.3: Зарегистрировать сервис в `TbankModule`
В `apps/backend/src/modules/tbank/tbank.module.ts`:
- Добавить `BrokerAnalyticsService` в `providers`
```typescript
import { BrokerAnalyticsService } from './services/broker-analytics.service';
@Module({
providers: [
// ... existing services
BrokerAnalyticsService,
],
})
```
#### Шаг 2.4: Добавить envelope DTO
В `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`:
```typescript
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
export class BrokerAnalyticsEnvelopeDto {
@ApiProperty({ type: BrokerAnalyticsDto })
data!: BrokerAnalyticsDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
```
#### Шаг 2.5: Добавить endpoint в `TbankController`
```typescript
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerAnalyticsEnvelopeDto } from './dto/broker-envelope.dto';
@Get('accounts/:accountId/analytics')
@ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
async getAnalytics(@Param('accountId') accountId: string) {
const result = await this.brokerAnalyticsService.getAnalytics(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
```
Добавить `private readonly brokerAnalyticsService: BrokerAnalyticsService` в конструктор.
#### Шаг 2.6: Проверить сборку
```bash
npm run build -w apps/backend
```
#### Шаг 2.7: Закоммитить
```bash
git add apps/backend/src/modules/tbank/
git add apps/backend/src/config/configuration.ts
git commit -m "feat(backend): add broker analytics endpoint with caching"
```
---
### Task 3: Frontend — shared types barrel, entity API и хук
**Files:**
- Modify: `apps/frontend/src/shared/api/index.ts`
- Modify: `apps/frontend/src/shared/api/types.ts` (codegen)
- Create: `apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts`
- Create: `apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts`
- Create: `apps/frontend/src/entities/broker-analytics/index.ts`
#### Шаг 3.1: Добавить тип `BrokerAnalyticsDto` в `types.ts`
Добавить в `components['schemas']` секцию `shared/api/types.ts`:
```typescript
BrokerAnalyticsDto: {
totalDeposits: number
totalWithdrawn: number
netInvested: number
totalDividends: number
totalCoupons: number
totalReceived: number
totalReturnPercent: number | null
currency: string
}
```
#### Шаг 3.2: Добавить брокерский тип в barrel export
В `apps/frontend/src/shared/api/index.ts`:
```typescript
// Broker analytics
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
```
#### Шаг 3.3: Создать API функцию
```typescript
// apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts
import type { ApiResponseMeta, BrokerAnalytics } from '@/shared/api'
import { request } from '@/shared/api/kyClient'
export function getBrokerAnalytics(
accountId: string,
): Promise<{ data: BrokerAnalytics; meta: ApiResponseMeta }> {
return request<BrokerAnalytics>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/analytics`,
)
}
```
#### Шаг 3.4: Создать хук
```typescript
// apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts
import { useQuery } from '@tanstack/react-query'
import type { BrokerAnalytics } from '@/shared/api'
import { getBrokerAnalytics } from '../api/brokerAnalyticsApi'
export function useBrokerAnalytics(accountId: string | undefined) {
return useQuery<BrokerAnalytics>({
queryKey: ['broker', 'analytics', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerAnalytics(accountId!)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
})
}
```
#### Шаг 3.5: Создать barrel export
```typescript
// apps/frontend/src/entities/broker-analytics/index.ts
export { getBrokerAnalytics } from './api/brokerAnalyticsApi'
export { useBrokerAnalytics } from './model/useBrokerAnalytics'
```
#### Шаг 3.6: Проверить сборку
```bash
npm run build -w apps/frontend
```
#### Шаг 3.7: Закоммитить
```bash
git add apps/frontend/src/entities/broker-analytics/
git add apps/frontend/src/shared/api/
git commit -m "feat(frontend): add broker analytics data layer"
```
---
### Task 4: Frontend — страница аналитики
**Files:**
- Create: `apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx`
- Create: `apps/frontend/src/pages/broker-analytics/index.ts`
#### Шаг 4.1: Создать страницу
```tsx
// apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { useBrokerAnalytics } from '@/entities/broker-analytics';
function formatAmount(value: number, currency: string): string {
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
export function BrokerAnalyticsPage() {
const { accountId } = useBrokerAccountContext();
const { data, isLoading, isError } = useBrokerAnalytics(accountId);
if (isLoading) {
return (
<section>
<div style={{ padding: '24px', color: 'var(--color-text-secondary)' }}>Загрузка...</div>
</section>
);
}
if (isError) {
return (
<section>
<div style={{ padding: '24px', color: 'var(--color-text-negative)' }}>
Не удалось загрузить аналитику
</div>
</section>
);
}
if (!data || (data.totalDeposits === 0 && data.totalReceived === 0)) {
return (
<section>
<div style={{ padding: '24px', color: 'var(--color-text-secondary)' }}>
Нет данных для аналитики
</div>
</section>
);
}
return (
<section>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
marginBottom: '24px',
}}>
{/* Вложено */}
<div style={{
background: 'var(--color-surface-card)',
borderRadius: '12px',
padding: '20px',
}}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Вложено</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Row label="Пополнения" value={formatAmount(data.totalDeposits, data.currency)} />
<Row label="Выводы" value={`${formatAmount(data.totalWithdrawn, data.currency)}`} negative />
<Divider />
<Row label="Нетто" value={formatAmount(data.netInvested, data.currency)} bold />
</div>
</div>
{/* Получено */}
<div style={{
background: 'var(--color-surface-card)',
borderRadius: '12px',
padding: '20px',
}}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Получено</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Row label="Дивиденды" value={formatAmount(data.totalDividends, data.currency)} positive />
<Row label="Купоны" value={formatAmount(data.totalCoupons, data.currency)} positive />
<Divider />
<Row label="Итого" value={formatAmount(data.totalReceived, data.currency)} bold positive />
</div>
</div>
</div>
{/* Сводка */}
<div style={{
background: 'var(--color-surface-card)',
borderRadius: '12px',
padding: '20px',
}}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Сводка</h3>
<div style={{ display: 'flex', gap: '48px' }}>
<SummaryItem label="Вложено нетто" value={formatAmount(data.netInvested, data.currency)} />
<SummaryItem label="Получено" value={formatAmount(data.totalReceived, data.currency)} />
{data.totalReturnPercent !== null && (
<SummaryItem
label="Доходность"
value={`${data.totalReturnPercent.toFixed(2)}%`}
positive
/>
)}
</div>
</div>
</section>
);
}
function Row({ label, value, bold, positive, negative }: {
label: string;
value: string;
bold?: boolean;
positive?: boolean;
negative?: boolean;
}) {
const color = positive
? 'var(--color-text-positive, #22c55e)'
: negative
? 'var(--color-text-negative, #ef4444)'
: 'var(--color-text-primary)';
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '14px' }}>{label}</span>
<span style={{ fontWeight: bold ? 600 : 400, color, fontSize: '15px' }}>{value}</span>
</div>
);
}
function Divider() {
return (
<div style={{
height: '1px',
background: 'var(--color-border)',
margin: '4px 0',
}} />
);
}
function SummaryItem({ label, value, positive }: {
label: string;
value: string;
positive?: boolean;
}) {
return (
<div>
<div style={{ color: 'var(--color-text-secondary)', fontSize: '13px', marginBottom: '4px' }}>
{label}
</div>
<div style={{
fontSize: '20px',
fontWeight: 600,
color: positive ? 'var(--color-text-positive, #22c55e)' : 'var(--color-text-primary)',
}}>
{value}
</div>
</div>
);
}
```
#### Шаг 4.2: Создать barrel
```typescript
// apps/frontend/src/pages/broker-analytics/index.ts
export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage'
```
#### Шаг 4.3: Проверить сборку
```bash
npm run build -w apps/frontend
```
#### Шаг 4.4: Закоммитить
```bash
git add apps/frontend/src/pages/broker-analytics/
git commit -m "feat(frontend): add broker analytics page"
```
---
### Task 5: Frontend — роут и таб навигации
**Files:**
- Modify: `apps/frontend/src/app/routing/routeTree.tsx`
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
#### Шаг 5.1: Добавить роут
В `apps/frontend/src/app/routing/routeTree.tsx`:
```typescript
import { BrokerAnalyticsPage } from '@/pages/broker-analytics'
const brokerAnalyticsRoute = createRoute({
getParentRoute: () => brokerAccountRoot,
path: '/analytics',
component: BrokerAnalyticsPage,
})
// Добавить в brokerAccountRoot.addChildren([...])
brokerAccountRoot.addChildren([
brokerAccountIndexRoute,
brokerSharesRoute,
brokerBondsRoute,
brokerOperationsRoute,
brokerEventsRoute,
brokerAnalyticsRoute, // <-- добавить
])
```
#### Шаг 5.2: Добавить таб в навигацию
В `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`:
```typescript
const links = [
{ to: '', label: 'Обзор' },
{ to: '/shares', label: 'Акции' },
{ to: '/bonds', label: 'Облигации' },
{ to: '/operations', label: 'Операции' },
{ to: '/events', label: 'События' },
{ to: '/analytics', label: 'Аналитика' },
]
```
#### Шаг 5.3: Проверить сборку
```bash
npm run build -w apps/frontend
```
#### Шаг 5.4: Закоммитить
```bash
git add apps/frontend/src/app/routing/routeTree.tsx
git add apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx
git commit -m "feat(frontend): add analytics route and tab"
```
---
### Task 6: Проверка линта и тестов
#### Шаг 6.1: Запустить линт и сборку
```bash
npm run lint -w apps/backend && npm run build -w apps/backend
npm run lint -w apps/frontend && npm run build -w apps/frontend
```
#### Шаг 6.2: Запустить тесты
```bash
npm test -w apps/backend -- --run
npm test -w apps/frontend -- --run
```
#### Шаг 6.3: Если всё ок — закоммитить финальные правки и запушить
```bash
git add -A
git commit -m "chore: fix lint and tests"
```