Files
specs/openspec/changes/group-account-rows-by-instrument/design.md
T
codex-bot f3a6b78461
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
spec: plan grouping of all-account rows by instrument (WealthLedger/main#47) (#64)
2026-10-30 07:28:31 +03:00

217 lines
29 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.
# Engineering Design
## Inputs
- Change: `wealth-ledger-specs`, `openspec/changes/group-account-rows-by-instrument/`.
- Утверждённые входы: `proposal.md`, `ux-design.md`, `specs/brokerage-account-ledger/spec.md`, `specs/brokerage-account-upcoming-events/spec.md`, `specs/brokerage-account-income-analytics/spec.md` относительно change.
- Issue: `WealthLedger/main#47`; рабочая ветка specs: `feature/47-group-rows-by-instrument`. При различии формулировки ISIN в Issue и утверждённых входов используется identity Instrument из входов.
- Проверенные источники реализации: `frontend/src/shared/api/generated/schema.d.ts`, `frontend/AGENTS.md`, `frontend/.ai/architecture.md`, conventions `engineering.md`, `typescript-react.md`, `ui.md`, `data.md`, `testing.md`.
- Ниже пути `src/` и `packages/` относятся к `frontend/`; этот документ не изменяет код. UX остаётся владельцем flows, визуальных решений и a11y.
## Architecture Drivers
- `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`: точный ключ, минимум две записи, отсутствие изменений single-account.
- `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`: сохранение каждого денежного движения, точность и запрет ложного итога.
- `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`: серверный порядок и только показанные записи.
- `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument`: дата вместе с `dateKind`, identity и точный тип.
- `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total`: неизвестный прогноз и неподтверждённая полнота не превращаются в ноль.
- `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`: ограничение превью до группировки.
- `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`: визуальная агрегация не заменяет записи или серверные аналитические итоги.
- Ограничения: существующие API и query lifecycle, локальный FSD profile, без новых runtime dependencies и без хранения производного состояния.
## Technical Goals / Non-Goals
- Реализовать одну чистую операцию группировки и переиспользуемую точную decimal-арифметику; предметные ключи остаются у владельцев сущностей/страницы.
- Сохранить число исходных записей, знаки, валюты, порядок, provenance и прежние ветки одиночных карточек.
- Не менять backend, фильтры, cursor protocol, аналитические totals, доступ, синхронизацию, URL или часовые пояса текущего отображения.
- Не вводить межстраничное накопление, поиск identity через сеть, валютную конвертацию, новый feature/widget или универсальный aggregation framework.
## Implementation Impact Map
| Area | Affected | Outcome or N/A reason |
| --- | --- | --- |
| main | Нет | Issue уже существует; workspace и tooling не меняются. |
| frontend | Да | Shared grouping/decimal, entity presentation, четыре page slices и EntryCard workspace package. |
| backend | Нет | DTO и envelope достаточны; endpoints и вычисления сервера остаются прежними. |
| docs | Да, после реализации | Уточнить AS-IS представление списков и границу клиентских итогов; точные страницы ниже. |
| specs | Да | Этот design; утверждённые proposal/specs/UX не переписываются. |
## Decisions
### D1. Одна чистая группировка, предметные ключи снаружи
Драйвер: `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`.
В `src/shared/lib/group-items.ts` разместить `groupItems<T>(items: readonly T[], keyOf: (item: T) => string | null)`.
Результат — discriminated union: single с исходным item либо group с key и исходными items (не менее двух).
Функция не знает DTO, денег, счетов, React и режима маршрута; `null` всегда оставляет самостоятельный single.
Map сохраняет позицию первого появления ключа; последующие совпадения добавляются к его записям без сортировки.
Вход не мутируется; ссылки на записи сохраняются, записи одного счёта не схлопываются. Сложность O(n), память O(n).
Три независимых реализации отклонены из-за дублирования инварианта; framework с reducers/plugins — избыточен.
### D2. Ключи соответствуют реально отображаемому дню
Драйверы: `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`; `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument`.
| Список | День | Identity: первое непустое поле | Тип и дополнительные поля |
| --- | --- | --- | --- |
| Журнал и последние операции | Локальный календарный день `occurredAt` в timezone браузера | `instrumentUid`, затем `figi` | Исходный `type` без нормализации |
| Выплаты | Тот же локальный день `occurredAt` | `instrumentUid`, затем `figi` | Исходный `type` |
| События и их превью | `date`, отображаемая через `formatDate` в UTC | `instrumentUid`, затем `figi`, затем `isin` | Исходный `type` и `dateKind` |
`LedgerEntryRow` передаёт `formatSnapshotTime(occurredAt)` и в обычную карточку, и в executed PayoutCard; formatter содержит дату и время без `timeZone` (`src/shared/lib/display.ts:132–139`).
Добавить рядом day-only formatter с тем же timezone браузера: использовать календарные части, не полную строку с временем и не `occurredAt.slice(0, 10)`.
`formatDate` уже фиксирует UTC (`display.ts:19–38`); для date-only события его ISO-дата достаточна как ключ.
`moscowDate` из `src/shared/lib/calendar.ts` обслуживает периоды/границы фильтров; не подменяет день отображения выплат.
Identity кодируется парой namespace/value; весь ключ — сериализованный tuple дня, identity, type и, для события, dateKind.
`account.id`, валюта, category, ticker, description и logo не входят в ключ. Пустая identity возвращает null.
`IncomeAnalyticsEntryDto.instrumentKey` — opaque membership аналитики, не доказанная identity Instrument: не использовать как fallback и не парсить.
Это подтверждает existing test `AccountIncomePage.test.tsx`, «opaque membership rather than instrument identity»: один instrumentKey допускает разные UID.
Сопоставление UID одной записи с FIGI другой через словарь aliases не вводится: нет авторитетного локального соответствия; одинаковый ticker не доказывает одну бумагу.
### D3. Точное сложение и отдельная политика допустимости итога
Драйверы: `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`.
В `shared/lib` сейчас есть string-formatting, проверка нуля и BigInt-округление, но нет экспортированного exact add.
Однако `src/pages/account/model/positions.ts:41–64,170–179` уже содержит decimal parse/serialize и точную сумму scaled BigInt.
Выделить только эту арифметику в `src/shared/lib/decimal.ts`, перевести существующего owner на неё; расчёт процентов и округление портфеля оставить локальными.
Публичная минимальная операция `sumDecimalStrings(values)` возвращает decimal string; внутри выравниваются scales и складываются signed BigInt, без Number/parseFloat и без округления.
Предусмотреть пустой ввод → `"0"`, канонический ноль без минуса, дробную часть любой поддержанной контрактом длины; не вводить библиотеку decimal.
Чистая `sumMoneyByCurrency` в shared принимает структурные `{ currency, value } | null`, возвращает список сумм либо недоступность; Map сохраняет первое появление валюты.
Если хотя бы один исходный amount отсутствует (а для журнала и «Выплат» также если он численно равен нулю), скрывается весь итог, а не только проблемная валюта. Некорректная decimal-строка также не превращается в ноль.
Только `meta?.completeness === "complete"` разрешает показ результата; отсутствие meta и `incomplete` запрещают его. Это predicate владельца ресурса, не арифметики.
Для ledger/income источник — только `payment`, не price/quantity/commission; для событий — `projectedAmount`, null означает неизвестный прогноз, включая offer.
Компенсация ненулевых слагаемых до нуля допустима: `+100 + (-100)` даёт видимый нейтральный ноль, в отличие от нулевого исходного amount.
Финальное представление — `formatMoney` с `withSign: true`, без `withRoundedCents`; серверные `totalsByCurrency`, buckets и instruments не пересчитываются.
### D4. Граница группировки — текущая выборка, не query cache
Драйверы: `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`; `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`.
`src/entities/broker-operation/model/useBrokerageOperationHistory.ts` хранит стек cursors и читает одну страницу: `onNext` добавляет cursor, `onPrevious` снимает его.
Это previous/next pagination, не infinite append: группировать только текущие `operations.data.items`; не объединять cached pages.
Тем самым «собирается только из показанных записей» выполняется буквально; `hasNext` не означает partial и не скрывает итог текущей группы.
Превью операций получает `limit: 5` в `AccountPage.vm.ts`; событий — `slice(0, RECENT_EVENTS_LIMIT)` в `pages/account/model/previews.ts`.
Группировать после этих ограничений; никогда не добирать шестую запись ради пары. При смене периода/счёта/cursor результат вычисляется заново.
### D5. Композиция вместо новых разновидностей каждой карточки
Драйверы: `brokerage-account-ledger` — `Full ledger presents compact rows`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`.
Общий недоменный `EntryGroup` в `src/shared/ui/EntryGroup/` принимает готовый header, accessible label и children; он владеет только композицией группы.
Предметные `LedgerEntryGroup` и `LedgerAccountRow` принадлежат `entities/broker-operation/ui`; `BrokerageEventGroup` и `BrokerageEventAccountRow` — `entities/brokerage-event/ui`.
Account row лёгкий, без вложенного полноценного LedgerEntryRow/Avatar; у выплат его существующие данные отображаются в карточном варианте, у остальных — строковом.
В `packages/ui/src/EntryCard/EntryCard.tsx` расширить только value slot с string до ReactNode (включая null); сохранить string callers и прежний DOM для них.
Слот допускает несколько самостоятельно оформленных валютных значений и отсутствие видимого значения; общий tone карточки не должен перекрашивать все валюты одним знаком.
Для одной внешней рамки использовать существующий `details` slot EntryCard под содержимое группы, не вкладывать вторую Paper вокруг целой карточки.
Singleton продолжает вызывать `LedgerEntryRow` либо `BrokerageEventCard`, а через них существующий `PayoutCard`; его API не расширяется ради grouping.
Альтернативы — копировать EntryCard или добавлять group/account варианты в PayoutCard — увеличивают дублирование либо смешивают ответственности.
### D6. Явный all-mode на границе page
Драйвер: `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`.
Page VM читает route param и передаёт `isAllAccounts = accountId === "all"`; это существующий sentinel, например `pages/account/ui/AccountPage/components/AccountHeading.view.tsx`.
При false сохраняется прежний flat-render path без вызова группировки и новой политики сумм. Наличие account у записи не используется как определение режима.
Не извлекать режим из URL внутри entity, имени счёта, количества счетов или query result.
## Components and Responsibilities
| Owner / путь | Ответственность и направление зависимостей |
| --- | --- |
| `shared/lib/group-items.ts`, `decimal.ts` | Недоменная чистая механика; экспорт через `shared/lib/index.ts`. |
| `entities/broker-operation/model/display.ts` | Ключ операции и group presentation; reuse `paymentPresentation`, `isIncomePayment`, `operationLabelKey`, а не category как type. |
| `entities/brokerage-event/model/event-key.ts` | Добавить отдельный grouping key; `brokerageEventKey` оставить ключом исходного события, не заменять им группировку. |
| `entities/*/ui/*Group`, `*AccountRow` | Преобразование предметных данных в shared UI; без queries и зависимости сущностей друг от друга. |
| `pages/account-income/model/display.ts` | Сохранить occurrence keys каждой выплаты, сформировать grouped sequence только в all; income DTO структурно подходит operation renderer. |
| `pages/account-operations`, `pages/account-events` | VM передаёт all и подтверждение полноты; loaded view выбирает single/group. |
| `pages/account/model/previews.ts` | Передать all/completeness каждого собственного ресурса после ограничения записей. |
| `RecentOperations.view.tsx`, `BrokerageEventsPreview.tsx` | Композиция preview single/group; состояния блоков не подменяют основной портфель. |
Импорты через public `index.ts`; page не импортирует соседнюю page. Компонент с тестом/CSS живёт в `Component/Component.tsx`, `.test.tsx`, `.module.css` по UI conventions; чистые тесты colocated `.test.ts`.
## Data / Storage Changes
N/A: новые данные не сохраняются. Группы и BigInt существуют только при вычислении presentation; ни localStorage, ни второй query cache, ни миграции БД не нужны.
## Contract Implementation
N/A для изменения контракта: проверены следующие существующие поля `src/shared/api/generated/schema.d.ts`.
| DTO | Счёт / дата / тип | Identity | Деньги |
| --- | --- | --- | --- |
| `BrokerageOperationDto` (1087) | `account?`, `occurredAt`, `type` | nullable `instrumentUid`, `figi`; ISIN нет | nullable `payment`, `price`, `accruedInterest`, `commission` |
| `UpcomingEventDto` (1229) | `account?`, `date`, `dateKind`, `type` | nullable `instrumentUid`, `figi`, `isin` | nullable `projectedAmount` |
| `IncomeAnalyticsEntryDto` (1158) | `account?`, `occurredAt`, `type` | nullable `instrumentUid`, `figi`, opaque `instrumentKey` | nullable `payment` и остальные поля операции |
`MoneyDto` содержит string `value` и `currency`; resource envelopes несут meta. Полей достаточно для алгоритма без нового endpoint, enrichment и ручных правок generated types.
Optional `account` нужен для single-account DTO; all-response должен фактически содержать `{id,name}` согласно `docs/domains/portfolio/all-accounts.md`. Типы сами эту гарантию не доказывают: проверить fixture/response на consumer boundary.
При отсутствующем account не угадывать происхождение и не строить ссылку из `all`; это нарушение данных, а не повод менять API в данном change.
## Cross-cutting Implementation
`src/shared/api/resource-query.ts:128–147` раскрывает data и meta одного envelope. Entity hooks и useScreenSync остаются владельцами загрузки и обновления.
Operations/events VM уже передают `source: query.meta` и `partial`; для итога использовать source.completeness, не инверсию partial.
Income VM сейчас передаёт только `partial: analytics.meta?.completeness === "incomplete"`; добавить явный `totalsAllowed` от meta и провести через loaded view в IncomePayments.
`previews.ts` теряет подтверждение complete при сведении к loaded/partial: добавить `totalsAllowed` отдельно для operations/events; meta портфеля здесь не подходит.
Stale/refresh failure не равны incomplete: сохранённые данные продолжают отображаться по прежней policy; новая группа не хранит собственный snapshot.
React keys групп — tuple-key; строки — прежняя identity с account и occurrence для повторений. Не дедуплицировать совпадающие события; индекс допустим лишь как occurrence внутри идентичного ключа.
Переводы и озвучивание остаются в presentation; shared арифметика не импортирует i18n. Новых telemetry, запросов и зависимостей нет.
## UI Implementation
Композиция: route → existing query/VM → текущие ограниченные items + all + totalsAllowed → pure grouping → entity single/group → shared EntryCard/EntryGroup.
Производные группы не помещаются в useState/effect; существующие состояния запроса и pagination остаются источником истины.
Ниже привязка к состояниям `ux-design.md#ui-states`, без переопределения их представления:
| UX state | Как производится |
| --- | --- |
| Одиночная запись | Single ветка результата или прежний single-account path. |
| Группа с итогом / мультивалютный итог | Два и более items; totalsAllowed; успешная сумма по валютам, переданная value slot. |
| Итог скрыт | Неподтверждённая completeness либо недопустимый amount; null visual value и перевод для accessible name. |
| Нулевой итог | Все слагаемые допустимы, точная сумма равна нулю; neutral presentation без знака. |
| Offer без сумм | Null projectedAmount; event-row использует существующий `events.noAmount`. |
| Прогноз | Event presenter использует `payout.forecast`, независимо от знака и числа валют. |
| Строка без денег | Existing payment semantics, нулевой/null amount; group total недоступен. |
| Обзор, 5 записей / граница страницы | D4; состав определяется до группировки, никаких additional fetch. |
| Loading / empty / error / partial-Alert | Existing VM/view guards; группировка только доступного массива, прежние alerts и SourceSyncStatus сохраняются. |
В all-ветке контейнеры loaded lists получают list semantics из UX; account-row использует данные счёта, не делает lookup через другой entity.
Подписи/дата/лого заголовка берутся из предметного presenter первой записи; у событий dateKind общий благодаря ключу.
EntryCard value slot и CSS module EntryGroup реализуют responsive/a11y контракт UX; отдельный локальный state раскрытия не создаётся.
## Developer Verification Strategy
Test Intent: доказать сохранение записей и денежных значений при изменении только композиции; риск — ложный итог, смешение типов/дней, межстраничная агрегация.
- Unit grouping: пустой список, одиночный ключ, null identity, не соседние совпадения, один счёт дважды, порядок первого появления и children, отсутствие мутации.
- Unit key adapters: разные type внутри одной category; UID/FIGI/ISIN priority; отсутствие identity; разный dateKind; одинаковый день с разным временем, граница суток в разных timezone.
- Unit decimal/money: разные scales, отрицательные дроби, значения больше safe integer, `0.1 + 0.2`, несколько валют, точная компенсация до нуля, null/zero/negative-zero, malformed input, пустой ввод.
- Regression выделенной арифметики: существующие `pages/account/model/positions.test.ts` и display tests проходят без изменения портфельных результатов.
- Component/route tests: все четыре поверхности all, singleton и single-account, account provenance, partial и отсутствующая meta, complete zero, unknown forecast; серверные income totals неизменны.
- Pagination через MSW: next/previous, тот же ключ на двух страницах, reset фильтра и invalid cursor; после перехода нет строк предыдущей страницы.
- Preview test: пять исходных записей, шестая не подтягивается; UI group не считается одной записью лимита. React keys не теряют повторные выплаты.
- RTL semantic queries проверяют nested lists, доступные имена и отсутствие ложного итога; browser proof — переносы, 320 px, 200% zoom и focus согласно UX, не проверки CSS через JSDOM.
- После загрузки `$HOME/.nvm/nvm.sh` и `nvm use --silent` из frontend: адресный `npm run test:run -- <path>`, `npm run typecheck`, `npm run lint`; на итоговом code diff — один `npm run verify`.
- Verify уже включает offline `openapi:check`, coverage, Knip, dependency boundaries, app/catalog build; отдельный runner или CI pipeline не добавлять. Для одного этого Markdown — semantic review и `git diff --check`, без application build.
## Migration / Rollout / Rollback
Сначала выделить арифметику с regression checks; затем pure grouping и entity composition; затем подключить all-ветки и провести meta до потребителей.
Существующие EntryCard callers сохраняют совместимость; выпуск возможен одним frontend build без координации backend или миграции cache/data.
Откат — предыдущий frontend build либо возврат flat-render all-веток; серверные данные и API не затронуты, feature flag ради этого change не нужен.
## Solution Assessment
N/A: нет новых auth, storage, integrations, caching, concurrency или shared tooling; изменение локально и обратимо. Риски точной арифметики и UI описаны проверками выше.
## Risks / Trade-offs
- В UX есть неоднозначность: Accessibility требует account links в строках вообще, Presentation Decisions п. 4 исключает обзор/события. До реализации ссылок владелец UX должен подтвердить область применения; дизайн её молча не меняет.
- UTC календарь событий, browser timezone исполненных записей и Moscow период аналитики различаются уже сейчас; намеренно не исправляем их этим change.
- Отсутствие общей identity или account нельзя восстановить из названия; проверка фактических all fixtures обязательна. instrumentKey не устраняет этот риск.
- Canonical «Account page presents an upcoming events list» говорит «без фильтров», а страница уже фильтрует период. Этот drift существовал до change и записан в WealthLedger/specs#63; фильтры в рамках группировки не трогаются.
## ADR Candidates
Отдельный ADR не требуется: API, архитектурный стиль и технологии не меняются; локальные обратимые решения D1–D6 достаточно сохранить в этом change.
## Docs Impact
Цели и итоговый AS-IS перечислены в разделе `## Docs Impact` в tasks.md, docs-impact не выбран. `docs/design/overview.md` не меняется.