spec: plan grouping of all-account rows by instrument (WealthLedger/main#47)
Conventional commits / commits (pull_request) Successful in 9s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 8s

This commit is contained in:
2026-10-30 07:12:37 +03:00
parent 322ddf6a7f
commit 9aa390b8e9
13 changed files with 1233 additions and 0 deletions
@@ -0,0 +1,216 @@
# 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` не меняется.