feat: complete broker account analytics with sync button, tests, and state filter
- Add state filter (EXECUTED/null) to analytics query (spec compliance) - Add service unit tests (11 tests) and controller test - Add sync button to operations page with mutation hook - Regenerate frontend types via codegen - Update tasks.md marking all items complete Backend: 114 tests, Frontend: 116 tests — all pass
This commit is contained in:
parent
ef4bc48e50
commit
3dfcf5aaa8
@ -0,0 +1,232 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { CacheService } from '../../cache/cache.service';
|
||||||
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
|
import { BrokerAnalyticsService } from './broker-analytics.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('BrokerAnalyticsService', () => {
|
||||||
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||||
|
const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService;
|
||||||
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
|
const acc1 = {
|
||||||
|
id: 'acc-1',
|
||||||
|
type: 'brokerage' as const,
|
||||||
|
name: 'Test Broker',
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
openedAt: null,
|
||||||
|
accessLevel: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockCachePassthrough() {
|
||||||
|
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||||
|
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||||
|
data: await fetchFn(),
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOp(type: string, value: number, state?: string | null) {
|
||||||
|
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 404 for missing account', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeros for account with no operations', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
totalDeposits: 0,
|
||||||
|
totalWithdrawn: 0,
|
||||||
|
netInvested: 0,
|
||||||
|
totalDividends: 0,
|
||||||
|
totalCoupons: 0,
|
||||||
|
totalReceived: 0,
|
||||||
|
totalReturnPercent: null,
|
||||||
|
currency: 'RUB',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates deposit types correctly', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_INPUT', 1000),
|
||||||
|
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
|
||||||
|
makeOp('OPERATION_TYPE_INP_MULTI', 200),
|
||||||
|
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
||||||
|
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
||||||
|
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
|
||||||
|
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.totalDeposits).toBe(2300);
|
||||||
|
expect(result.data.totalWithdrawn).toBe(0);
|
||||||
|
expect(result.data.netInvested).toBe(2300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates withdrawal types with absolute value', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
||||||
|
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
||||||
|
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
||||||
|
makeOp('OPERATION_TYPE_OUT_MULTI', -50),
|
||||||
|
makeOp('OPERATION_TYPE_INPUT', 1000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.totalDeposits).toBe(1000);
|
||||||
|
expect(result.data.totalWithdrawn).toBe(850);
|
||||||
|
expect(result.data.netInvested).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates dividend and coupon types', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_DIVIDEND', 300),
|
||||||
|
makeOp('OPERATION_TYPE_DIV_EXT', 150),
|
||||||
|
makeOp('OPERATION_TYPE_COUPON', 75),
|
||||||
|
makeOp('OPERATION_TYPE_COUPON', 25),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.totalDividends).toBe(450);
|
||||||
|
expect(result.data.totalCoupons).toBe(100);
|
||||||
|
expect(result.data.totalReceived).toBe(550);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates totalReturnPercent correctly', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_INPUT', 10000),
|
||||||
|
makeOp('OPERATION_TYPE_DIVIDEND', 500),
|
||||||
|
makeOp('OPERATION_TYPE_COUPON', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.netInvested).toBe(10000);
|
||||||
|
expect(result.data.totalReceived).toBe(700);
|
||||||
|
expect(result.data.totalReturnPercent).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null totalReturnPercent when netInvested <= 0', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
||||||
|
makeOp('OPERATION_TYPE_DIVIDEND', 100),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.netInvested).toBe(-500);
|
||||||
|
expect(result.data.totalReturnPercent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed payment JSON gracefully', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
|
||||||
|
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.totalDeposits).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-executed and non-null state operations', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
type: { in: expect.any(Array) },
|
||||||
|
payment: { not: null },
|
||||||
|
OR: [
|
||||||
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||||
|
{ state: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { type: true, payment: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds all monetary values to 2 decimal places', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_INPUT', 100.336),
|
||||||
|
makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.totalDeposits).toBe(100.34);
|
||||||
|
expect(result.data.totalDividends).toBe(50.79);
|
||||||
|
expect(result.data.totalReceived).toBe(50.79);
|
||||||
|
expect(result.data.netInvested).toBe(100.34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps result in ApiResponse envelope through cache', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
vi.mocked(cache.getOrFetch).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
totalDeposits: 1000,
|
||||||
|
totalWithdrawn: 0,
|
||||||
|
netInvested: 1000,
|
||||||
|
totalDividends: 0,
|
||||||
|
totalCoupons: 0,
|
||||||
|
totalReceived: 0,
|
||||||
|
totalReturnPercent: null,
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
fromCache: true,
|
||||||
|
cachedAt: '2026-06-24T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.netInvested).toBe(1000);
|
||||||
|
expect(result.meta.fromCache).toBe(true);
|
||||||
|
expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -67,6 +67,10 @@ export class BrokerAnalyticsService {
|
|||||||
accountId,
|
accountId,
|
||||||
type: { in: Array.from(ANALYTICS_TYPES) },
|
type: { in: Array.from(ANALYTICS_TYPES) },
|
||||||
payment: { not: null },
|
payment: { not: null },
|
||||||
|
OR: [
|
||||||
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||||
|
{ state: null },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
select: { type: true, payment: true },
|
select: { type: true, payment: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -63,6 +63,31 @@ describe('TBankController', () => {
|
|||||||
expect(response.data).toEqual({ upserted: 2 });
|
expect(response.data).toEqual({ upserted: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes analytics endpoint through controller', async () => {
|
||||||
|
const analyticsData = {
|
||||||
|
totalDeposits: 1000,
|
||||||
|
totalWithdrawn: 200,
|
||||||
|
netInvested: 800,
|
||||||
|
totalDividends: 150,
|
||||||
|
totalCoupons: 50,
|
||||||
|
totalReceived: 200,
|
||||||
|
totalReturnPercent: 25,
|
||||||
|
currency: 'RUB',
|
||||||
|
};
|
||||||
|
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
|
||||||
|
data: analyticsData,
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
|
const response = await controller.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||||
|
expect(response).toBeInstanceOf(ApiResponse);
|
||||||
|
expect(response.data).toEqual(analyticsData);
|
||||||
|
expect(response.meta).toEqual({ fromCache: false, cachedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('forwards events query and wraps response', async () => {
|
it('forwards events query and wraps response', async () => {
|
||||||
const eventsData = {
|
const eventsData = {
|
||||||
items: [],
|
items: [],
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api'
|
import type {
|
||||||
|
ApiResponseMeta,
|
||||||
|
BrokerOperationSyncResponse,
|
||||||
|
BrokerOperationsPage,
|
||||||
|
} from '@/shared/api'
|
||||||
import { request } from '@/shared/api/kyClient'
|
import { request } from '@/shared/api/kyClient'
|
||||||
|
|
||||||
export type BrokerOperationQuery = {
|
export type BrokerOperationQuery = {
|
||||||
@ -11,6 +15,17 @@ export type BrokerOperationQuery = {
|
|||||||
state?: string
|
state?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncBrokerOperations(
|
||||||
|
accountId: string,
|
||||||
|
query: { from: string; to: string },
|
||||||
|
): Promise<{ data: BrokerOperationSyncResponse; meta: ApiResponseMeta }> {
|
||||||
|
return request<BrokerOperationSyncResponse>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations/sync`,
|
||||||
|
{ from: query.from, to: query.to },
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function getBrokerOperations(
|
export function getBrokerOperations(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: BrokerOperationQuery = {},
|
query: BrokerOperationQuery = {},
|
||||||
|
|||||||
@ -7,3 +7,4 @@ export {
|
|||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
} from './model/operationFilters'
|
} from './model/operationFilters'
|
||||||
export { useBrokerOperations } from './model/useBrokerOperations'
|
export { useBrokerOperations } from './model/useBrokerOperations'
|
||||||
|
export { useSyncBrokerOperations } from './model/useSyncBrokerOperations'
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { syncBrokerOperations } from '../api/brokerOperationApi'
|
||||||
|
|
||||||
|
export function useSyncBrokerOperations(accountId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (range: { from: string; to: string }) => {
|
||||||
|
if (!accountId) throw new Error('Account ID required')
|
||||||
|
return (await syncBrokerOperations(accountId, range)).data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['broker', 'analytics', accountId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['broker', 'events', accountId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,18 +1,29 @@
|
|||||||
import { Heading, Text } from '@moex-vibe/design-system'
|
import { Button, Heading, Text } from '@moex-vibe/design-system'
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
useBrokerOperations,
|
useBrokerOperations,
|
||||||
|
useSyncBrokerOperations,
|
||||||
} from '@/entities/broker-operation'
|
} from '@/entities/broker-operation'
|
||||||
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
|
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
|
||||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
|
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
|
||||||
|
|
||||||
|
function getDefaultSyncRange() {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(Date.UTC(now.getUTCFullYear(), 0, 1))
|
||||||
|
return {
|
||||||
|
from: start.toISOString(),
|
||||||
|
to: now.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function BrokerOperationsPage() {
|
export function BrokerOperationsPage() {
|
||||||
const { accountId } = useBrokerAccountContext()
|
const { accountId } = useBrokerAccountContext()
|
||||||
|
const sync = useSyncBrokerOperations(accountId)
|
||||||
const [searchParams, setSearchParams] = useSearchParamsCompat()
|
const [searchParams, setSearchParams] = useSearchParamsCompat()
|
||||||
const urlType = searchParams.get('type')
|
const urlType = searchParams.get('type')
|
||||||
const selectedType = isBrokerOperationType(urlType) ? urlType : ''
|
const selectedType = isBrokerOperationType(urlType) ? urlType : ''
|
||||||
@ -56,14 +67,24 @@ export function BrokerOperationsPage() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function handleSync() {
|
||||||
|
const range = getDefaultSyncRange()
|
||||||
|
sync.mutate(range)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="section" aria-labelledby="broker-operations-heading">
|
<Box component="section" aria-labelledby="broker-operations-heading">
|
||||||
<Box
|
<Box
|
||||||
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
|
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
|
||||||
>
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'end', gap: 2 }}>
|
||||||
<Heading level={2} id="broker-operations-heading">
|
<Heading level={2} id="broker-operations-heading">
|
||||||
Операции
|
Операции
|
||||||
</Heading>
|
</Heading>
|
||||||
|
<Button variant="secondary" size="small" loading={sync.isPending} onClick={handleSync}>
|
||||||
|
Синхронизировать
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
<Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
|
<Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
|
||||||
<Text variant="label">Тип операции</Text>
|
<Text variant="label">Тип операции</Text>
|
||||||
<select value={selectedType} onChange={handleTypeChange}>
|
<select value={selectedType} onChange={handleTypeChange}>
|
||||||
@ -76,6 +97,16 @@ export function BrokerOperationsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
{sync.isSuccess && (
|
||||||
|
<Text component="p" tone="positive" style={{ marginBottom: '12px' }}>
|
||||||
|
Синхронизировано: {sync.data?.upserted} операций
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sync.isError && (
|
||||||
|
<Text component="p" tone="negative" style={{ marginBottom: '12px' }}>
|
||||||
|
Ошибка синхронизации
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{history}
|
{history}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -63,3 +63,6 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'
|
|||||||
|
|
||||||
// Broker analytics
|
// Broker analytics
|
||||||
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
|
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
|
||||||
|
|
||||||
|
// Broker sync
|
||||||
|
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']
|
||||||
|
|||||||
@ -468,6 +468,23 @@ export interface paths {
|
|||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
'/api/v1/broker/accounts/{accountId}/analytics': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
/** Get broker account profitability analytics */
|
||||||
|
get: operations['TBankController_getAnalytics']
|
||||||
|
put?: never
|
||||||
|
post?: never
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
'/api/v1/broker/accounts/{accountId}/operations/sync': {
|
'/api/v1/broker/accounts/{accountId}/operations/sync': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
@ -1193,6 +1210,15 @@ export interface components {
|
|||||||
actualCouponsTotal: number
|
actualCouponsTotal: number
|
||||||
actualPrincipalRepaymentTotal: number
|
actualPrincipalRepaymentTotal: number
|
||||||
}
|
}
|
||||||
|
BrokerEventsDataDto: {
|
||||||
|
items: components['schemas']['BrokerEventItemDto'][]
|
||||||
|
summary: components['schemas']['BrokerEventsSummaryDto']
|
||||||
|
asOf: string
|
||||||
|
}
|
||||||
|
BrokerEventsEnvelopeDto: {
|
||||||
|
data: components['schemas']['BrokerEventsDataDto']
|
||||||
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
|
}
|
||||||
BrokerAnalyticsDto: {
|
BrokerAnalyticsDto: {
|
||||||
totalDeposits: number
|
totalDeposits: number
|
||||||
totalWithdrawn: number
|
totalWithdrawn: number
|
||||||
@ -1203,13 +1229,8 @@ export interface components {
|
|||||||
totalReturnPercent: number | null
|
totalReturnPercent: number | null
|
||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
BrokerEventsDataDto: {
|
BrokerAnalyticsEnvelopeDto: {
|
||||||
items: components['schemas']['BrokerEventItemDto'][]
|
data: components['schemas']['BrokerAnalyticsDto']
|
||||||
summary: components['schemas']['BrokerEventsSummaryDto']
|
|
||||||
asOf: string
|
|
||||||
}
|
|
||||||
BrokerEventsEnvelopeDto: {
|
|
||||||
data: components['schemas']['BrokerEventsDataDto']
|
|
||||||
meta: components['schemas']['BrokerResponseMetaDto']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerOperationSyncResponseDto: {
|
BrokerOperationSyncResponseDto: {
|
||||||
@ -1977,6 +1998,27 @@ export interface operations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TBankController_getAnalytics: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path: {
|
||||||
|
accountId: string
|
||||||
|
}
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['BrokerAnalyticsEnvelopeDto']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
TBankController_syncOperations: {
|
TBankController_syncOperations: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем
|
- [x] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем
|
||||||
- [ ] **T2** Controller endpoint, module registration, cache key, config TTL
|
- [x] **T2** Controller endpoint, module registration, cache key, config TTL
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics`
|
- [x] **T3** Shared типы, entity API, хук `useBrokerAnalytics`
|
||||||
- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков
|
- [x] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков
|
||||||
- [ ] **T5** Роут `/analytics` + таб в навигации
|
- [x] **T5** Роут `/analytics` + таб в навигации
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
- [ ] **T6** Линт, сборка, тесты
|
- [x] **T6** Линт, сборка, тесты
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user