diff --git a/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts new file mode 100644 index 0000000..6aa9a89 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts @@ -0,0 +1,27 @@ +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; +} diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts new file mode 100644 index 0000000..e129eb4 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -0,0 +1,110 @@ +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'); + + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.analytics, + [accountId], + () => this.computeAnalytics(accountId), + 'tbankAnalyticsTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + + private async computeAnalytics(accountId: string): Promise { + 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', + }; + } +} diff --git a/docs/features/broker-account-analytics/plan.md b/docs/features/broker-account-analytics/plan.md new file mode 100644 index 0000000..2b8cc3e --- /dev/null +++ b/docs/features/broker-account-analytics/plan.md @@ -0,0 +1,652 @@ +# Аналитика прибыльности брокерского счёта — 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 { + 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( + `/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({ + 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 ( +
+
Загрузка...
+
+ ); + } + + if (isError) { + return ( +
+
+ Не удалось загрузить аналитику +
+
+ ); + } + + if (!data || (data.totalDeposits === 0 && data.totalReceived === 0)) { + return ( +
+
+ Нет данных для аналитики +
+
+ ); + } + + return ( +
+
+ {/* Вложено */} +
+

Вложено

+
+ + + + +
+
+ + {/* Получено */} +
+

Получено

+
+ + + + +
+
+
+ + {/* Сводка */} +
+

Сводка

+
+ + + {data.totalReturnPercent !== null && ( + + )} +
+
+
+ ); +} + +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 ( +
+ {label} + {value} +
+ ); +} + +function Divider() { + return ( +
+ ); +} + +function SummaryItem({ label, value, positive }: { + label: string; + value: string; + positive?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} +``` + +#### Шаг 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" +``` diff --git a/docs/features/broker-account-analytics/tasks.md b/docs/features/broker-account-analytics/tasks.md new file mode 100644 index 0000000..864f391 --- /dev/null +++ b/docs/features/broker-account-analytics/tasks.md @@ -0,0 +1,16 @@ +# Аналитика прибыльности брокерского счёта — Tasks + +## Backend + +- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем +- [ ] **T2** Controller endpoint, module registration, cache key, config TTL + +## Frontend + +- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics` +- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков +- [ ] **T5** Роут `/analytics` + таб в навигации + +## Проверка + +- [ ] **T6** Линт, сборка, тесты