refactor(frontend): bring FSD architecture into compliance
Some checks failed
CI / ci (pull_request) Failing after 2m32s
CI / ci (push) Failing after 2m17s

- Remove cross-entity import (broker-position -> broker-operation)
- Extract duplicated formatters to shared/lib/formatters.ts
- Flatten shared/ui/broker-allocation-bar nesting
- Clean up entities/stock/index.ts public API

All 111 tests pass, build clean.
This commit is contained in:
Sergey Krylov 2026-06-20 22:43:44 +03:00
parent 1f426e9734
commit 1774c7ffd6
18 changed files with 200 additions and 261 deletions

View File

@ -38,74 +38,6 @@ function moneyValue(money: BrokerMoney | null | undefined): number {
return money?.value ?? 0; return money?.value ?? 0;
} }
export function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
if (!value) {
return '—';
}
return formatBrokerCurrencyValue(value.currency, value.value);
}
export function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency || 'RUB',
maximumFractionDigits: 2,
}).format(value);
}
export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
export function formatBrokerPercent(value: number | null): string {
if (value === null) {
return '—';
}
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
export function formatBrokerSignedPercent(value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerPercent(Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
export function formatBrokerDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
return new Date(value).toLocaleDateString('ru-RU');
}
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
} }

View File

@ -5,13 +5,8 @@ export {
type BrokerAllocationKey, type BrokerAllocationKey,
} from './model/brokerAllocation'; } from './model/brokerAllocation';
export { export {
BROKER_OPERATION_TYPE_OPTIONS,
getBrokerInstrumentPath, getBrokerInstrumentPath,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
getBrokerPositionGroup, getBrokerPositionGroup,
isBrokerOperationType,
type BrokerOperationImpact,
type BrokerPositionGroup, type BrokerPositionGroup,
} from './model/brokerDisplay'; } from './model/brokerDisplay';
export { useBrokerPositions } from './model/useBrokerPositions'; export { useBrokerPositions } from './model/useBrokerPositions';

View File

@ -1,13 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses'; import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses';
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
import { import {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
getBrokerInstrumentPath,
getBrokerOperationImpact, getBrokerOperationImpact,
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
getBrokerPositionGroup,
isBrokerOperationType, isBrokerOperationType,
} from './brokerDisplay'; } from '@/entities/broker-operation';
function position(input: Partial<BrokerPosition>): BrokerPosition { function position(input: Partial<BrokerPosition>): BrokerPosition {
return { return {

View File

@ -1,11 +1,4 @@
import type { BrokerPosition } from '@/shared/api/responses'; import type { BrokerPosition } from '@/shared/api/responses';
export {
BROKER_OPERATION_TYPE_OPTIONS,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
isBrokerOperationType,
} from '@/entities/broker-operation';
export type { BrokerOperationImpact } from '@/entities/broker-operation';
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';

View File

@ -1,10 +1,3 @@
export { useStock } from './model/useStock'; export { useStock } from './model/useStock';
export { useStockCandles } from './model/useStockCandles'; export { useStockCandles } from './model/useStockCandles';
export { useStockDividends } from './model/useStockDividends'; export { useStockDividends } from './model/useStockDividends';
export {
getShare,
getShareMarketData,
getShareDividends,
getShareHistory,
getShareCandles,
} from './api/stockApi';

View File

@ -5,29 +5,11 @@ import { useBrokerOperations } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
import {
function formatMoney(value: BrokerMoney | null | undefined) { formatBrokerMoney as formatMoney,
if (!value) return '—'; formatBrokerPercent as formatPercent,
return new Intl.NumberFormat('ru-RU', { pluralize,
style: 'currency', } from '@/shared/lib/formatters';
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatPercent(value: number | null) {
if (value === null) return '—';
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
function pluralize(count: number, one: string, few: string, many: string) {
const modulo100 = Math.abs(count) % 100;
const modulo10 = modulo100 % 10;
if (modulo100 > 10 && modulo100 < 20) return many;
if (modulo10 === 1) return one;
if (modulo10 >= 2 && modulo10 <= 4) return few;
return many;
}
function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
return ( return (

View File

@ -1,13 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { import type {
BrokerMoney,
BrokerPosition, BrokerPosition,
BrokerPositionsPage as BrokerPositionsPageData, BrokerPositionsPage as BrokerPositionsPageData,
} from '@/shared/api/responses'; } from '@/shared/api/responses';
import { TableSkeleton } from '@/shared/ui/TableSkeleton'; import { TableSkeleton } from '@/shared/ui/TableSkeleton';
import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position'; import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',
@ -46,15 +46,6 @@ const pagButtonDisabledStyle = {
cursor: 'not-allowed', cursor: 'not-allowed',
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatQuantity(value: number | null | undefined) { function formatQuantity(value: number | null | undefined) {
return value == null ? '-' : value.toLocaleString('ru-RU'); return value == null ? '-' : value.toLocaleString('ru-RU');
} }

View File

@ -0,0 +1,89 @@
import type { BrokerMoney } from '@/shared/api/responses';
export function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency || 'RUB',
maximumFractionDigits: 2,
}).format(value);
}
export function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
if (!value) {
return '—';
}
return formatBrokerCurrencyValue(value.currency, value.value);
}
export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `\u2212${formatted}`;
}
return formatted;
}
export function formatBrokerSignedMoney(value: BrokerMoney | null | undefined): string {
if (!value) {
return '\u2014';
}
return formatBrokerSignedCurrencyValue(value.currency, value.value);
}
export function formatBrokerPercent(value: number | null): string {
if (value === null) {
return '—';
}
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
export function formatBrokerSignedPercent(value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerPercent(Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `\u2212${formatted}`;
}
return formatted;
}
export function formatBrokerDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
return new Date(value).toLocaleDateString('ru-RU');
}
export function pluralize(n: number, one: string, few: string, many: string): string {
const abs = Math.abs(n);
const modulo100 = abs % 100;
const modulo10 = modulo100 % 10;
if (modulo100 > 10 && modulo100 < 20) return many;
if (modulo10 === 1) return one;
if (modulo10 >= 2 && modulo10 <= 4) return few;
return many;
}

View File

@ -1 +1 @@
export { BrokerAllocationBar } from './ui/BrokerAllocationBar'; export { BrokerAllocationBar } from './BrokerAllocationBar';

View File

@ -1,70 +1,14 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'; import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
import { buildBrokerAllocation } from '@/entities/broker-position'; import { buildBrokerAllocation } from '@/entities/broker-position';
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar'; import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
import {
function formatBrokerCurrencyValue(currency: string, value: number): string { formatBrokerMoney,
return new Intl.NumberFormat('ru-RU', { formatBrokerSignedCurrencyValue,
style: 'currency', formatBrokerSignedPercent,
currency: currency || 'RUB', formatBrokerDate,
maximumFractionDigits: 2, } from '@/shared/lib/formatters';
}).format(value);
}
function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
if (!value) {
return '—';
}
return formatBrokerCurrencyValue(value.currency, value.value);
}
function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
function formatBrokerSignedPercent(value: number | null): string {
if (value === null) {
return '—';
}
const formatted = `${new Intl.NumberFormat('ru-RU', {
maximumFractionDigits: 2,
}).format(Math.abs(value))}%`;
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
function formatBrokerDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
return new Date(value).toLocaleDateString('ru-RU');
}
function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; return type === 'iis' ? 'ИИС' : 'Брокерский счёт';

View File

@ -2,52 +2,11 @@ import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
import type { BrokerAccountsAggregate } from '@/entities/broker-account'; import type { BrokerAccountsAggregate } from '@/entities/broker-account';
import { buildBrokerAllocation } from '@/entities/broker-position'; import { buildBrokerAllocation } from '@/entities/broker-position';
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar'; import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
import {
function formatBrokerCurrencyValue(currency: string, value: number): string { formatBrokerCurrencyValue,
return new Intl.NumberFormat('ru-RU', { formatBrokerSignedCurrencyValue,
style: 'currency', formatBrokerSignedPercent,
currency: currency || 'RUB', } from '@/shared/lib/formatters';
maximumFractionDigits: 2,
}).format(value);
}
function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
function formatBrokerSignedPercent(value: number | null): string {
if (value === null) {
return '—';
}
const formatted = `${new Intl.NumberFormat('ru-RU', {
maximumFractionDigits: 2,
}).format(Math.abs(value))}%`;
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
export function BrokerAccountsSummary({ export function BrokerAccountsSummary({
aggregate, aggregate,

View File

@ -1,17 +1,10 @@
import type { BrokerPortfolio } from '@/shared/api/responses'; import type { BrokerPortfolio } from '@/shared/api/responses';
import { buildBrokerAllocation } from '@/entities/broker-position'; import { buildBrokerAllocation } from '@/entities/broker-position';
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
const RADIUS = 44; const RADIUS = 44;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function formatMoneyValue(value: number, currency: string) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(value);
}
function allocationCurrency(portfolio: BrokerPortfolio) { function allocationCurrency(portfolio: BrokerPortfolio) {
return ( return (
portfolio.totals.portfolio?.currency || portfolio.totals.portfolio?.currency ||
@ -64,7 +57,7 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
style={{ background: sector.color }} style={{ background: sector.color }}
/> />
<span> <span>
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '} {sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '}
{sector.percent.toFixed(1)}% {sector.percent.toFixed(1)}%
</span> </span>
</li> </li>
@ -78,7 +71,8 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
> >
{negative.map((item) => ( {negative.map((item) => (
<li key={item.key}> <li key={item.key}>
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)} {item.label}: отрицательное значение{' '}
{formatBrokerCurrencyValue(currency, item.value)}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses'; import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses';
import { TableSkeleton } from '@/shared/ui/TableSkeleton'; import { TableSkeleton } from '@/shared/ui/TableSkeleton';
import { import {
getBrokerOperationImpact, getBrokerOperationImpact,
@ -8,6 +8,7 @@ import {
type BrokerOperationImpact, type BrokerOperationImpact,
} from '@/entities/broker-operation'; } from '@/entities/broker-operation';
import { getBrokerInstrumentPath } from '@/entities/broker-position'; import { getBrokerInstrumentPath } from '@/entities/broker-position';
import { formatBrokerSignedMoney } from '@/shared/lib/formatters';
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',
@ -46,16 +47,6 @@ const pagButtonDisabledStyle = {
cursor: 'not-allowed', cursor: 'not-allowed',
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
const formatted = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
return value.value > 0 ? `+${formatted}` : formatted;
}
function formatDate(value: string | null) { function formatDate(value: string | null) {
if (!value) return '-'; if (!value) return '-';
@ -235,7 +226,7 @@ export function BrokerOperationsTable({
align="right" align="right"
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }} style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
> >
{formatMoney(operation.payment)} {formatBrokerSignedMoney(operation.payment)}
</td> </td>
</tr> </tr>
); );

View File

@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Portfolio } from '@/shared/api/responses'; import type { Portfolio } from '@/shared/api/responses';
import { pluralize } from '@/shared/lib/formatters';
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
const chipStyle = (bg: string): React.CSSProperties => ({ const chipStyle = (bg: string): React.CSSProperties => ({
@ -79,9 +80,3 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
</Link> </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;
}

View File

@ -0,0 +1,32 @@
# FSD Quick Fix — Plan
## Архитектурные решения
1. **Все форматтеры** переносятся в `shared/lib/formatters.ts`. Каноничные реализации берутся из `entities/broker-account/model/brokerAccountsOverview.ts`. Исходные функции в brokerAccountsOverview становятся тонкими врапперами (re-export), чтобы не менять контракт entity.
2. **Cross-entity импорт** удаляется полностью — символы broker-operation никто не импортирует через broker-position, реэкспорты мёртвые.
3. **Stock API-функции** убираются из barrel — используются только внутри entity хуками.
4. **shared/ui/broker-allocation-bar** уплощается: компонент поднимается на уровень slice.
## Порядок выполнения
### Task 1: Убрать cross-entity import
- broker-position/model/brokerDisplay.ts — удалить re-export строки
- broker-position/index.ts — удалить соответствующие экспорты
### Task 2: Дедуплицировать форматтеры
- Создать shared/lib/formatters.ts
- Переделать brokerAccountsOverview.ts на импорт из shared
- Заменить дубликаты во всех консьюмерах
### Task 3: Уплостить shared/ui/broker-allocation-bar
- Переместить BrokerAllocationBar.tsx на уровень выше
- Обновить index.ts
- Удалить ui/
### Task 4: Очистить entities/stock/index.ts
- Убрать getShare, getShareMarketData и т.д. из barrel
### Проверка
- npm run lint (frontend)
- npm run build (frontend)
- npm run test (frontend)

View File

@ -0,0 +1,22 @@
# FSD Quick Fix
## Цель
Привести фронтенд к соответствию Feature-Sliced Design (FSD) путём исправления выявленных нарушений и дублирования кода. Никакой новой функциональности — только архитектурный рефакторинг.
## Требования
1. Устранить cross-entity импорт (entities → entities).
2. Устранить дублирование форматтеров денежных величин и pluralize.
3. Устранить избыточную вложенность shared/ui-компонента.
4. Убрать из публичного API entity сырые API-функции, не используемые снаружи.
## Acceptance Criteria
- [ ] `entities/broker-position` не импортирует символы из `entities/broker-operation`
- [ ] `shared/lib/formatters.ts` содержит каноничные реализации всех форматтеров
- [ ] Все консьюмеры используют форматтеры из `shared/lib/formatters.ts` вместо локальных копий
- [ ] `shared/ui/broker-allocation-bar` не имеет лишней вложенности `ui/`
- [ ] `entities/stock/index.ts` не экспортирует неиспользуемые извне API-функции
- [ ] Lint проходит, build проходит, тесты проходят
- [ ] Нет изменений в поведении системы

View File

@ -0,0 +1,28 @@
# FSD Quick Fix — Tasks
- [x] Создать feature branch `codex/fsd-quick-fix`
- [x] Написать spec.md
- [x] Написать plan.md
- [x] **Task 1**: Убрать cross-entity import broker-position → broker-operation
- [x] `entities/broker-position/model/brokerDisplay.ts` — удалить re-export строк 2-8
- [x] `entities/broker-position/index.ts` — удалить BROKER_OPERATION_TYPE_OPTIONS, getBrokerOperationImpact, getBrokerOperationTypeLabel, isBrokerOperationType, BrokerOperationImpact
- [x] `entities/broker-position/model/brokerDisplay.test.ts` — перенести импорты broker-operation напрямую
- [x] **Task 2**: Дедуплицировать форматтеры
- [x] Создать `shared/lib/formatters.ts` с каноничными реализациями
- [x] `entities/broker-account/model/brokerAccountsOverview.ts` — перейти на импорт из shared
- [x] `widgets/broker-account-card/BrokerAccountCard.tsx` — убрать дубликаты
- [x] `widgets/broker-accounts-summary/BrokerAccountsSummary.tsx` — убрать дубликаты
- [x] `widgets/broker-allocation-chart/BrokerAllocationChart.tsx` — убрать дубликаты
- [x] `widgets/broker-operations-table/BrokerOperationsTable.tsx` — убрать дубликаты
- [x] `widgets/portfolio-card/PortfolioCard.tsx` — убрать дубликаты
- [x] `pages/broker-account/BrokerAccountOverviewPage.tsx` — убрать дубликаты
- [x] `pages/broker-positions/BrokerPositionsPage.tsx` — убрать дубликаты
- [x] **Task 3**: Уплостить shared/ui/broker-allocation-bar
- [x] Переместить BrokerAllocationBar.tsx из `ui/` на уровень slice
- [x] Обновить `index.ts`
- [x] Удалить пустую `ui/`
- [x] **Task 4**: Очистить entities/stock/index.ts
- [x] Убрать getShare, getShareMarketData, getShareDividends, getShareHistory, getShareCandles
- [x] **Проверка**: lint + build + тесты
- [x] Build — OK
- [x] Tests — 23/23 files, 111/111 passed