Files
codex-bot a2d2df3b5a
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 7s
spec: archive group-account-rows-by-instrument and sync canonical specs (WealthLedger/main#47) (#65)
2026-10-30 07:35:30 +03:00

29 KiB
Raw Permalink Blame History

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 не меняется.