19 KiB
Raw Blame History

Аналитика прибыльности брокерского счёта — 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

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

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: Проверить сборку

npm run build -w apps/backend

Шаг 1.4: Закоммитить

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:

export const TBANK_CACHE_KEYS = {
  // ... existing keys
  analytics: 'tbank:analytics',
} as const;

Шаг 2.2: Добавить TTL config

В apps/backend/src/config/configuration.ts, в секцию cache:

tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),

Шаг 2.3: Зарегистрировать сервис в TbankModule

В apps/backend/src/modules/tbank/tbank.module.ts:

  • Добавить BrokerAnalyticsService в providers
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:

import { BrokerAnalyticsDto } from './broker-analytics-response.dto';

export class BrokerAnalyticsEnvelopeDto {
  @ApiProperty({ type: BrokerAnalyticsDto })
  data!: BrokerAnalyticsDto;

  @ApiProperty({ type: BrokerResponseMetaDto })
  meta!: BrokerResponseMetaDto;
}

Шаг 2.5: Добавить endpoint в TbankController

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: Проверить сборку

npm run build -w apps/backend

Шаг 2.7: Закоммитить

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:

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:

// Broker analytics
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']

Шаг 3.3: Создать API функцию

// 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: Создать хук

// 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

// apps/frontend/src/entities/broker-analytics/index.ts
export { getBrokerAnalytics } from './api/brokerAnalyticsApi'
export { useBrokerAnalytics } from './model/useBrokerAnalytics'

Шаг 3.6: Проверить сборку

npm run build -w apps/frontend

Шаг 3.7: Закоммитить

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: Создать страницу

// 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

// apps/frontend/src/pages/broker-analytics/index.ts
export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage'

Шаг 4.3: Проверить сборку

npm run build -w apps/frontend

Шаг 4.4: Закоммитить

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:

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:

const links = [
  { to: '', label: 'Обзор' },
  { to: '/shares', label: 'Акции' },
  { to: '/bonds', label: 'Облигации' },
  { to: '/operations', label: 'Операции' },
  { to: '/events', label: 'События' },
  { to: '/analytics', label: 'Аналитика' },
]

Шаг 5.3: Проверить сборку

npm run build -w apps/frontend

Шаг 5.4: Закоммитить

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: Запустить линт и сборку

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: Запустить тесты

npm test -w apps/backend -- --run
npm test -w apps/frontend -- --run

Шаг 6.3: Если всё ок — закоммитить финальные правки и запушить

git add -A
git commit -m "chore: fix lint and tests"