spec: plan account concentration tab (WealthLedger/main#48)

This commit is contained in:
2026-10-31 13:42:27 +03:00
parent b9ec11f5ba
commit e3cc9c8d25
23 changed files with 3441 additions and 0 deletions
@@ -0,0 +1,319 @@
# Engineering Design
Design описывает, как backend и frontend реализуют утверждённые Specs и модули analysis для change `account-top-positions`. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](analysis/system-analysis.md); Requirement указываются по названию заголовка. Пути с `backend/` и `frontend/` отсчитываются от workspace; база кода — backend `d37fa2c`, frontend `71126c9`. Design и [ux-design.md](analysis/ux-design.md) образуют один Design package с одним approval на G3. UX здесь не пересказывается: этот документ описывает только инженерную реализацию.
## Inputs
- Specs (G2 approved): `specs/portfolio-concentration/spec.md` (28 Requirement), `specs/portfolio-view/spec.md` (PVΔ «Нормализованный состав Portfolio», PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента»).
- Analysis (G2 approved): [system-analysis.md](analysis/system-analysis.md) (контракты, семантическая delta `PortfolioPositionDto`, заметка о дублировании Alert), [data-mapping.md](analysis/data-mapping.md) (M1–M3, источники-кандидаты в T-Bank, инварианты I1–I8), [failure-model.md](analysis/failure-model.md) (классификация исходов, F01–F16).
- Design package (G3): [ux-design.md](analysis/ux-design.md), Visual Package **v2** (`visuals/*.svg`, SHA-256 — в ux-design «Materials»).
- Параллельно с design: [qa-analysis.md](analysis/qa-analysis.md) и `analysis/test-cases/` (oracle для расчётов).
- Ограничения: `backend/AGENTS.md` (integrations, data, http-api conventions), `frontend/AGENTS.md` (`.ai/architecture.md`, `.ai/conventions/ui.md`, `.ai/conventions/data.md`), ADR 015, 017, 019 (`docs/adr/`).
- `analysis/contracts/` нет. Target-контракт — таблица «Семантическая delta `PortfolioPositionDto`» в system-analysis.
## Architecture Drivers
| ID | Driver | Источник |
| --- | --- | --- |
| D1 | Сектор и бренд хранятся как последнее известное значение. Обновление — при первом обходе, начавшемся не ранее чем через 24 ч после последнего **успешного** получения; пока значение не получено — при каждом обходе. Сектор и бренд независимы | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента» |
| D2 | Четыре исхода получения: заменить, `null`, сохранить прежнее; отсчёт сбрасывается или не меняется | PVΔ «Результат получения сектора и эмитента», failure-model «Классификация» |
| D3 | Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: fallback не возвращается, `400 INVALID_TBANK_TOKEN` | PVΔ «Свежесть и fallback полного Portfolio Snapshot», failure-model F08 |
| D4 | Прочие отказы не мешают публикации Snapshot и не меняют `isStale` или `lastSuccessfulAt` | PVΔ «Нормализованный состав Portfolio», PVΔ «Результат получения сектора и эмитента», F02–F12 |
| D5 | GET не обращается к T-Bank | PVΔ «Свежесть и fallback полного Portfolio Snapshot»; ADR 019 |
| D6 | Квоты T-Bank: каждый RPC проходит через общий RateLimiter (`TBANK_RATE_LIMIT_PER_SECOND`, по умолчанию 5/с) и bounded queue на 50 элементов | `backend/src/integrations/tbank/tbank.client.ts:147-190,52`; integrations convention; Q-F3 |
| D7 | Аддитивный DTO: `sector`, `issuer{id,name}` присутствуют всегда и nullable; в старых Snapshot — `null` | system-analysis «Семантическая delta», «Совместимость» |
| D8 | Точные суммы и доли (в сумме ровно 100 %), округление до десятых с серединой вверх, «< 0,1 %» | PC «Расчёт долей», PC «Показ долей», PC «Суммарная доля топ-5» |
| D9 | Разрез и валюта сохраняются при переключении вкладок страницы и сбрасываются при новом заходе или смене счёта | PC «Начальное состояние вкладки» |
| D10 | На вкладке «Концентрация» одно сообщение о неполноте; страница его не дублирует | PC «Предупреждение о неполных данных»; system-analysis «Заметка для design» |
| D11 | Информация диаграммы передаётся не только цветом; есть текстовый эквивалент; в центре две строки | PC «Диаграмма распределения разреза», PC «Центр диаграммы», PC «Доступность диаграммы без цвета»; ux-design «Accessibility», D7 |
| D12 | FSD-границы, вычисление в VM без effect, generated types берутся только из предоставленного snapshot | `frontend/.ai/architecture.md`, `ui.md`, `data.md` |
## Technical Goals / Non-Goals
**Goals:**
- получать сектор и бренд в существующем часовом шаге Portfolio без нового фонового процесса;
- хранить их с возрастом каждого поля отдельно;
- добавить два DTO-поля;
- считать разрезы на клиенте чистой функцией с точной десятичной арифметикой.
**Non-Goals:**
- менять общую metadata инструментов (`InstrumentMetadataService`) и её семантику свежести;
- переписывать сохранённые Snapshot;
- конвертировать валюты;
- добавлять новые библиотеки, очереди, таймеры или процессы;
- исправлять `formatShare` в карточке позиции (см. Risks);
- менять каталог акций и облигаций.
## Implementation Impact Map
| Область | Затронута | Результат или причина N/A |
| --- | --- | --- |
| main | нет | Root содержит только workspace-навигацию; кода и контрактов там нет |
| backend | да | Prisma-модель и аддитивная миграция; 3 метода `TbankClient` и opt-in обработка NOT_FOUND; `PositionClassificationService`; шаг в `PortfolioService.queueSnapshot` до publish; параметр начала обхода из `TbankHourlyScheduler`; DTO `sector`/`issuer`; нормализация старых Snapshot в `saved()` |
| frontend | да | Новый openapi snapshot и `schema.d.ts`; `pages/account/model/concentration.ts`; state вкладки, разреза и валюты в `AccountPage.vm.ts`; управляемые `Tabs` и подавление Alert в `AccountPageLoaded`; view `Concentration/`; ключ `stock.sectors.government` в `ru.ts` |
| specs | да | Этот `design.md`. Specs не меняются; findings — в разделе Risks. Contract Parity на G4 сравнивает generated OpenAPI с delta из system-analysis |
| docs | да | Объём определяет docs-impact. Инженерные затронутые места: исключение из «справочник онлайн» в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019, домен Portfolio (поля позиции, вкладка), страница интеграции T-Bank (ShareBy/BondBy/EtfBy/GetAssetBy), глоссарий |
## Decisions
### Backend
**B1. Отдельная таблица классификации, а не общий metadata-кэш** (D1, D2, D4)
- **Контекст.** Общий кэш хранит один JSON `TbankInstrument.metadata` из GetInstrumentBy. Он считает `fresh = complete` без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:295-323`). `Instrument` не содержит `sector`, а его `brand` — это `BrandData`, а не `Brand{uid,name}` (data-mapping «Что уже есть в коде»). Запрос metadata запускается асинхронно после publish и при каждой ошибке перезаписывает или отбрасывает весь payload (`instrument-metadata.service.ts:111-222`).
- **Выбрано.** Новая глобальная (не персональная) таблица `TbankInstrumentClassification`. Состав — в разделе Data / Storage. Возраст сектора и бренда хранится отдельно: `sectorFetchedAt`, `brandFetchedAt`.
- **Отвергнуто:** расширение metadata JSON и `selectMetadata`; переиспользование `TbankInstrumentSource`; хранение в payload Snapshot. Причины — в Solution Assessment.
**B2. Получение — синхронный шаг attempt Portfolio до publish** (D3, D4, D5)
Шаг встраивается в `queueSnapshot` (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:262-283`):
1. GetPortfolio и GetPositions.
2. `normalize`.
3. **`classification.refresh(token, positions, walkStartedAt)`** — новый шаг.
4. `mapPortfolio` записывает `sector`/`issuer` из возвращённой Map в Snapshot.
5. `repository.publish`.
Шаг стоит **до** publish, потому что `publish` переводит attempt в `complete` (`portfolio.repository.ts:121-131`), а `rejectMembershipToken` меняет только `running` attempt (`tbank-access.service.ts:233-251`). После publish отказ token не пометил бы подключение недействительным. До publish ошибка `InvalidToken` попадает в существующий `catch` (`portfolio.service.ts:285-291`): Snapshot не публикуется, подключение получает `invalidToken: true`, GET отвечает `400 INVALID_TBANK_TOKEN` через `TbankAccessService.local` (`tbank-access.service.ts:270-272`). Это ровно D3, новых путей ошибок нет.
Все остальные ошибки шага, включая ошибку записи в БД, ловит `refresh`. Он логирует событие `portfolio.classification.failed` только с `code` (без userId, токена и позиций) и возвращает ранее сохранённые значения. Publish продолжается (D4).
Отвергнуто: асинхронный шаг после publish по образцу metadata. Он требует отдельного durable work на каждый инструмент и отдельного fence для отказа token, а новых сведений не добавляет.
**B3. Начало обхода передаётся явно** (D1)
`TbankHourlyScheduler.walk` (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:134`) фиксирует `walkStartedAt = new Date()` и передаёт его через `dispatchPortfolio` в `portfolio.request(userId, revision, accountId, true, walkStartedAt)` (`:280`). Сейчас других вызывающих `request` нет.
Правило: поле нужно получать, если `fetchedAt == null` или `walkStartedAt − fetchedAt ≥ 24 ч`. `fetchedAt` — момент получения ответа, как в сценариях PVΔ «Обновление сектора и эмитента». Фактический период обновления ≈ 25 ч принят владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)). Отвергнуто: брать время из durable-строки `TbankSyncWork`, потому что строка переиспользуется по `scopeKey` и не хранит время старта.
**B4. Вызовы T-Bank** (D2, D6)
- Новые методы `TbankClient`:
- `getEtf` (`etfBy`, sparse service, как `getBond`);
- `getAssetBrand(token, assetUid)` → `{assetUid, brand{uid,name}} | null` (`getAssetBy`);
- необязательный параметр `notFound: true` в `getShare`/`getBond`/`getEtf`.
Существующие `getShare`/`getBond`/`getAssetDescription` остаются для каталога без изменений.
- **NOT_FOUND.** Сейчас NOT_FOUND превращается в `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальные методы получают Unavailable (`tbank.client.ts:577-583`). Приватный `call()` получает opt-in флаг, и условие становится `opt-in || shareBy || metadataCall`. Opt-in включают только новые вызовы классификации. Глобальное расширение списка методов отвергнуто: для `getAssetBy` оно превратило бы отсутствие описания в карточке компании в 404 вместо 503, а для `bondBy` поменяло бы ответ каталога облигаций. Это observable-изменения вне scope.
- **Порядок для позиции STOCK/BOND/FUND:** `ShareBy`/`BondBy`/`EtfBy` по UID; при NOT_FOUND по UID и наличии FIGI — один повтор по FIGI (тот же порядок, что в PVΔ «Нормализованный состав Portfolio» и metadata). Это design-решение в рамках формулировки data-mapping «по instrument UID, затем по FIGI»: формулировка не уточняет, нужен ли переход на FIGI после NOT_FOUND по UID, и design его выбирает. Specs и data-mapping не меняются. Ответ даёт `sector` и `asset_uid`. Затем `GetAssetBy(asset_uid)`, если бренд нужно получать. Если инструмент в этом обходе не получен, используется сохранённый `assetUid`.
- **Дедупликация.** Singleton-сервис держит Map in-flight promises: ключ инструмента для `*By` и `asset_uid` для `GetAssetBy`. Повтор внутри одного шага и параллельные attempt разных счетов с одним инструментом или активом делят один RPC. После завершения promise удаляется: это не кэш.
- **Квоты.** Цикл последовательный (`await` на каждый инструмент), все RPC проходят через существующий `call()` с RateLimiter, retry и deadline. Вложенного retry нет. Переполнение очереди даёт Unavailable, то есть исход «иная ошибка». Ожидаемый объём — 2 RPC на инструмент раз в сутки плюс повторы при неуспехе; до 200 инструментов это около 80 с при 5/с, в пределах `WORK_WAIT_BOUND_MS` 15 мин (`tbank-hourly.scheduler.ts:30`).
**B5. Отображение исходов** (D2; failure-model «Классификация»)
| Наблюдение | Действие с полем | `*FetchedAt` |
| --- | --- | --- |
| Непустое значение после `trim`; для бренда — непустая пара `uid`/`name` | Записать | `now` |
| `NotFoundException`, пустое поле, нет `asset_uid`, нет актива или бренда, неполная пара | `null` | `now` |
| `uid`/`figi` ответа ≠ запрошенному; `asset.uid` ≠ `asset_uid` | Не менять | Не менять |
| Unavailable, queue full, ошибка декодирования, ошибка БД | Не менять | Не менять |
| `InvalidToken` | Пробросить (B2) | — |
Типы `OTHER`, `CURRENCY` и прочие не запрашиваются: `sector`/`issuer` = `null` (PVΔ «Нормализованный состав Portfolio»). Для FUND `issuer` заполняется, и разрез «Эмитенты» его отбрасывает на клиенте (PC «Состав разрезов»).
**B6. DTO и OpenAPI** (D7)
- `PortfolioPositionDto` (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`) получает поля:
- `@ApiProperty({ type: String, nullable: true }) sector`;
- `@ApiProperty({ type: PortfolioIssuerDto, nullable: true }) issuer`, где у `PortfolioIssuerDto` обязательные `id` и `name`.
Используется тот же шаблон nullable-объекта, что у `currentValue: MoneyDto | null`. Это OpenAPI 3.0, синтаксис 3.1 не вводится.
- Backend: `npm run openapi:generate` записывает корневой `openapi.json` (не в Git). Frontend по правилу `frontend/.ai/conventions/data.md` «Типы и contract» получает artifact явно: он кладётся в `frontend/src/shared/api/spec/openapi.json` в frontend PR, затем `npm run openapi:generate` и `npm run openapi:check`. Frontend не читает backend checkout.
**B7. Старые Snapshot** (D7)
`PortfolioService.saved()` при чтении payload (`portfolio.service.ts:90`) нормализует каждую позицию: `sector: p.sector ?? null`, `issuer: p.issuer ?? null`. Сохранённые payload не переписываются. Следующий успешный часовой обход публикует Snapshot с полями. Data-миграции нет.
### Frontend
**F1. Чистая функция разрезов в `pages/account/model/concentration.ts`** (D8, D12)
- **Слой.** Вкладка нужна только странице счёта, поэтому `pages/account`, а не widget или entity (`frontend/.ai/architecture.md:32`).
- **Сигнатура.** `toConcentration(portfolio, slice, currency)` → `{ currencies, items, other, total, top5Share, unaccounted, empty }`.
- **Математика.** Используются только `decimalFromString`/`sumDecimals`/`decimalToString`/`formatDecimal` из `@/shared/lib` и константы масштаба по образцу `positions.ts` (`PERCENT_SCALE`, `formatPositionReturnPercent` с округлением середины вверх на bigint, `frontend/src/pages/account/model/positions.ts:40-69`). Доля считается как bigint-частное с запасом масштаба; показ — округление до десятых с серединой вверх; положительная доля < 0,1 даёт «< 0,1 %». Доля «Прочее» и «Топ-5» — частное от точной суммы, поэтому сумма точных долей = 100 % по построению.
- `formatShare` из `entities/portfolio/model/display.ts` не используется: он считает через `Number`.
- Порядок равных сумм (PC «Порядок элементов с равной суммой») и выбор имени эмитента (PC «Название эмитента при разных названиях бренда») реализуются внутри модели. Тесты — по oracle из `analysis/test-cases/`.
**F2. Композиция** (PC «Вкладка «Концентрация» страницы счёта», PC «Вкладка в состояниях страницы»)
- В `AccountPageLoaded` (`frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:108-112`) добавляется `Tabs.Tab value="concentration"` сразу после «Позиции», только при `accountId !== "all"`. Состояние `loaded` уже исключает загрузку, ошибку и пустой Portfolio (`AccountPage.vm.ts:110-121`), поэтому PC «Вкладка в состояниях страницы» выполняется без новой логики.
- Панель — `components/Concentration/Concentration.view.tsx` (+ `.module.css`). View получает готовую модель и именованные действия; data hooks внутри view нет (`ui.md` «Container, VM и view»).
**F3. State в page VM** (D9, D10)
- `useAccountPageViewModel` владеет `{ accountId, tab, slice, currency }` в одном `useState`. Если сохранённый `accountId` ≠ текущему, VM при render выдаёт значения по умолчанию (`positions`, `stocks`, первая валюта) без effect. Так смена счёта считается новым заходом. Повторное открытие маршрута монтирует страницу заново.
- `Tabs` становятся управляемыми (`value`/`onChange`), `keepMounted={false}` сохраняется: state живёт выше панели.
- Если выбранной валюты нет в новом `currencies` (данные обновились), модель берёт первую валюту.
- `toConcentration` вызывается в VM только при `tab === "concentration"`. Мемоизация не нужна: функция линейна по числу позиций.
**F4. Подавление страничного Alert** (D10)
В `AccountPageLoaded.view.tsx:77-79`: `props.partial ? (tab !== "concentration" && <Alert …investment.partial/>) : <CurrencyCards/>`. На вкладке показывается собственный `Alert role="status"` «Данные неполные» (PC «Предупреждение о неполных данных»). Остальные вкладки не меняются. Карточки валют при неполноте не показываются и на этой вкладке, как в AS-IS. Источник: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
**F5. DonutChart без прямого recharts** (D11; ux-design D7)
Проверено на `@mantine/charts` 9.7.3 (`frontend/package.json:41`, `node_modules/@mantine/charts/lib/DonutChart/DonutChart.d.ts:28-64`):
- `chartLabel` выводит только одну строку `<text>`. Поэтому центр — HTML-оверлей `Text` в `position: relative` контейнере поверх `DonutChart`: две строки, точная сумма без обрезки, ступенчатое уменьшение шрифта через CSS. Оверлей помечен `aria-hidden`, текстовый эквивалент даёт `aria-describedby`.
- `cellProps(series)` пробрасывает SVG-атрибуты в каждую ячейку (`node_modules/@mantine/charts/esm/DonutChart/DonutChart.mjs:51,72-78`: `cellProps` передаётся в построение `pieData`, которое уходит в `<Pie data>`). Отсюда задаются обводка и `fill="url(#…)"` для штриховки «Прочее» и «не указан». Узоры (`<pattern>`) лежат в отдельном скрытом `<svg width=0 height=0 aria-hidden>` в той же панели: ссылки `url(#id)` действуют в пределах документа, id берутся из `useId`.
- Цифры ранга на сегментах ≥ 4 % рисует `pieProps.label` (render-функция). `pieProps` разворачивается после встроенного `label`, поэтому он его переопределяет.
- `role="img"`, `aria-label`, `aria-describedby` передаются через `...others` на root `Box`.
- `name` в data — уникальный ключ строки; тип ячейки находится по ключу в замыкании `cellProps`.
Прямой `recharts` (уже зависимость 3.10.1) не нужен. Отвергнуто: собственный SVG-donut — лишний код без driver.
**F6. Подписи секторов** (PC «Подписи секторов», PC «Русские подписи известных секторов»)
Используется шаблон `t(\`stock.sectors.${sector}\`, { defaultValue: sector })` (`frontend/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx:64`) и словарь `stock.sectors` (`frontend/src/shared/i18n/locales/ru.ts:759-770`). Добавляется недостающий ключ `government: "Государственный сектор"` в этот общий словарь. По решению владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) подпись показывается и в каталогах — везде, где выводится сектор через этот словарь (страница акции). Список вариантов фильтра скринера (`frontend/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx:46-62`) не меняется: требования к фильтру нет. Неизвестный код показывается как есть через `defaultValue`. **Это observable-изменение вне Specs:** подпись на странице акции меняется с сырого `government` на «Государственный сектор». Оно одобрено владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)) и фиксируется в Gate Log G3; Specs не дополняются. Проверка — task 2.7. Новые строки вкладки добавляются в `portfolio.concentration.*`.
## Components and Responsibilities
| Компонент | Ответственность | Зависит от |
| --- | --- | --- |
| `TbankClient` (+`getEtf`, `getAssetBrand`, opt-in NOT_FOUND) | Типизированные RPC через общую очередь | gRPC, RateLimiter |
| `PositionClassificationService` (portfolio/application) | Решает, нужно ли получать данные (B3); выполняет RPC и дедупликацию (B4); отображает исходы (B5); пробрасывает только `InvalidToken`; возвращает Map `instrumentKey → {sector, issuer}` | `TbankClient`, `PortfolioRepository` |
| `PortfolioRepository` (+`readClassifications`, `saveClassification`) | Чтение и upsert строк классификации | Prisma |
| `PortfolioService` | Встраивает шаг до publish; нормализует старые Snapshot | Сервис выше |
| `tbank-portfolio.mapper` | Записывает `sector`/`issuer` в позицию по ключу `metadataKey(position)` | — |
| `TbankHourlyScheduler` | Передаёт `walkStartedAt` | — |
| `pages/account/model/concentration.ts` | Разрезы, суммы, доли, «Прочее», N не учтённых, валюты | `@/shared/lib` |
| `AccountPage.vm.ts` | State вкладки, разреза и валюты; вызов модели | модель |
| `AccountPageLoaded` / `Concentration.view` | Композиция, Alert, диаграмма, список | Mantine, `@mantine/charts` |
Направление зависимостей не меняется: integrations ← modules/portfolio ← tbank-sync; `pages → entities → shared`.
## Data / Storage Changes
Новая Prisma-модель (SQLite, `backend/prisma/schema.prisma`) в стиле `TbankInstrumentLookup`:
```prisma
// Public per-instrument classification; no holdings or credentials.
model TbankInstrumentClassification {
provider String
instrumentKey String // metadataKey(position): JSON [uid|null, figi|null]
sector String?
sectorFetchedAt DateTime?
assetUid String?
brandUid String?
brandName String?
brandFetchedAt DateTime?
@@id([provider, instrumentKey])
}
```
- Ключ — существующий `metadataKey` (`instrument-metadata.repository.ts:33-38`), без FK на `TbankInstrument`: строка инструмента может ещё не существовать (B1).
- Данные публичные и не принадлежат пользователю или подключению, как и `TbankInstrument`. Fence по connection revision не нужен. Публикация Snapshot по-прежнему ограждена (`portfolio.repository.ts:121-131`).
- Миграция `…_add_instrument_classification` (`prisma migrate dev --create-only`) делает только `CREATE TABLE`. Прежние таблицы и payload не меняются.
- Snapshot (`TbankPortfolio.payload`) получает два поля у позиций. Схема JSON неформальная, поэтому миграция не нужна.
## Contract Implementation
| Контракт | Producer → Consumer | Как производится / потребляется | Сверка с target |
| --- | --- | --- | --- |
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio`, `positions[].sector`/`issuer` | Backend → frontend | Nest DTO (B6) → `npm run openapi:generate` → artifact передаётся явно → `frontend/src/shared/api/spec/openapi.json` → `npm run openapi:generate` → generated `paths` | Contract Parity на G4: поля, nullable, required и `issuer.{id,name}` сравниваются с таблицей «Семантическая delta» system-analysis; `npm run test:openapi-contract` подтверждает, что генерация совпадает с runtime |
| T-Bank `ShareBy`/`BondBy`/`EtfBy`/`GetAssetBy` | T-Bank → backend | Через существующий proto loader; vendor proto не меняется (RPC уже есть: `instruments.proto:24,42,65,92`) | Unit-тесты client на маппинг полей и NOT_FOUND |
Ошибки HTTP не меняются: `400 INVALID_TBANK_TOKEN` выдаёт существующий путь.
## Cross-cutting Implementation
- **Security.** Токен расшифровывается только в attempt (`tokenForAttempt`) и в логи не попадает. Raw gRPC наружу не уходит. Новых прав и секретов нет (security-analysis не выбран, process-record).
- **Observability.** Используется существующий `tbank.rpc.failed` на каждый RPC и одно событие `portfolio.classification.failed { code }` на неуспешный шаг. Новых метрик нет (observability не выбран).
- **Concurrency.** In-flight Map в singleton (B4). Upsert одной строки из двух attempt идемпотентен, побеждает последняя запись с тем же содержимым. Один процесс backend — по ADR 015 и scheduler (`ponytail` в `tbank-hourly.scheduler.ts:42-43`).
- **Freshness.** Классификация не входит в `meta.dependencies` и не меняет `isStale` или `lastSuccessfulAt` (D4). `dataRevision` Snapshot и так растёт при каждой публикации.
## UI Implementation
С ux-design описывается только инженерная часть.
| UI-состояние | Как получается |
| --- | --- |
| Вкладки нет | `accountId === "all"` либо VM-состояние не `loaded` |
| Разрез / валюта / раскрытие «Прочее» | `slice`, `currency` — в VM (F3); раскрытие — локальный `useState` view: сброс при уходе с вкладки допустим, потому что PC «Раскрытие строки «Прочее»» не требует его сохранять |
| Пустой разрез | `model.empty`; выбор валюты и сноска остаются (PC «Пустое состояние разреза») |
| «Данные неполные» | `partial` из VM (`AccountPage.vm.ts:124`) → Alert вкладки; страничный подавлен (F4) |
| Выбор валюты скрыт | `currencies.length <= 1` |
| Сноска N | `model.unaccounted > 0` |
| Центр и текстовый эквивалент | `model.total`, `model.top5Share` → оверлей и скрытый текст (F5) |
Visual Package: используется v2 из ux-design (локальные `visuals/*.svg`, хеши — в ux-design «Materials»). В design новых визуальных материалов нет: реализация не добавляет экранов и состояний сверх v2.
## Developer Verification Strategy
Проверки задают implementation owners. QA-матрица — в qa-analysis.
**backend** (команды из `backend/AGENTS.md` и conventions):
- `npx prisma validate`, `npx prisma generate`, `npm run typecheck:app`;
- проверка миграции на disposable копии БД с существующими Snapshot: старые payload читаются, GET отдаёт `sector: null` и `issuer: null`;
- unit-тесты `PositionClassificationService`: четыре исхода по каждому полю отдельно; границы 23:59 и 24:00 от `walkStartedAt`; «не получено → каждый обход»; дедупликация по `asset_uid`; использование сохранённого `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет;
- `portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован и вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован с прежними значениями;
- `tbank.client.spec.ts`: opt-in NOT_FOUND для `bondBy`/`etfBy`/`getAssetBy`; прежние методы не изменились;
- `npm run test:openapi-contract`, `npm run openapi:generate`, `npm run test:e2e` (portfolio HTTP: shape и `400 INVALID_TBANK_TOKEN`);
- финально `npm run verify`.
**frontend** (команды из `frontend/AGENTS.md`):
- предоставленный `openapi.json` → `npm run openapi:generate`, `npm run openapi:check`;
- vitest для `concentration.ts` по oracle из `analysis/test-cases/`;
- `AccountPage.vm.test.tsx`: сохранение разреза и валюты между вкладками, сброс при смене счёта;
- `AccountPage.test.tsx`: вкладки нет для `all`; Alert подавлен только на «Концентрации»;
- browser-тест раскладки 320 px по образцу `position-layout.browser.test.tsx`;
- финально `npm run verify`.
**specs:** `openspec validate account-top-positions --strict`, `git diff --check`.
**docs:** `npm run verify` (`docs/CONTRIBUTING.md`).
## Migration / Rollout / Rollback
**Порядок выката:**
1. Backup БД и ключа шифрования по ADR 015.
2. Backend: миграция `CREATE TABLE` и код. Новые поля сразу в DTO (`null` до первого обхода).
3. Публикация artifact OpenAPI.
4. Frontend PR с новым snapshot и вкладкой.
Совместимость: старый frontend игнорирует новые поля, поэтому изменение аддитивно. Первый обход после выката запрашивает данные всех позиций: разовый всплеск RPC, B4.
**Откат:**
- **Frontend.** Revert PR; backend продолжает отдавать поля.
- **Backend.** Откат на предыдущий bundle (ADR 016). Лишняя таблица не читается.
- Фактический запуск: production image стартует `node dist/src/main.js` (`backend/Dockerfile`, `CMD`); приложение миграции не применяет и `_prisma_migrations` не проверяет (`backend/src/prisma/prisma.service.ts`). Миграции применяет отдельный compose-сервис `migration` (`prisma migrate deploy`, `deploy/compose.yaml:32-37`), от которого зависит `backend` (`depends_on … service_completed_successfully`, `:43-45`). Штатный `deploy.sh` запускает его на каждом обновлении (`deploy/deploy.sh:183-186`); ручной image rollback по `deploy/README.md` «Ручной откат и восстановление» делает `up --no-deps backend` и миграцию намеренно не запускает (то же — `docs/architecture/deployment.md` «Откат image»).
- Следствие: при ручном откате старый backend стартует на схеме с лишней таблицей, запись о новой миграции в `_prisma_migrations` остаётся. Если вместо ручного отката запустить `deploy.sh` со старым тегом, старый `migrate deploy` увидит применённую миграцию, которой нет в его каталоге. По документации Prisma `migrate deploy` применяет только недостающие локальные миграции и не сверяет историю, а `migrate status` сообщает о ней как о «applied but missing locally»; это не подтверждено на версии проекта и проверяется в task 3.4. Snapshot с полями `sector`/`issuer` читается старым кодом без ошибок: поля лишние, ответ проходит через сериализацию DTO. На G4 это нужно проверить: whitelist сериализации в backend не найден, поэтому старый код может отдавать лишние поля как есть, и это безвредно.
- **БД.** `DROP TABLE "TbankInstrumentClassification"` — по желанию, данные восстанавливаются следующим обходом. Restore из backup не нужен.
Необратимых шагов нет.
## Solution Assessment
Обязателен: новое хранение и кеширование данных внешней интеграции.
| Критерий | A. Новая таблица + шаг до publish (**выбрано**) | B. Расширить `InstrumentMetadataService` / `TbankInstrument.metadata` | C. Хранить в payload Snapshot с переносом из прежнего |
| --- | --- | --- | --- |
| D1 возраст по полю | Явные столбцы | Поля возраста внутри JSON; меняется `fresh = complete`, а через `metadataDependencies` и `meta` GET (`portfolio.service.ts:122-138`) | Перенос между Snapshot; при новом подключении (новая revision) значения теряются |
| D2 четыре исхода | Простой upsert по полю | Drain перезаписывает весь payload и бросает `Incomplete metadata` / `mismatch` (`instrument-metadata.service.ts:141-186`): исходы смешиваются с логикой name/ticker | Реализуемо, но дублируется на каждый счёт |
| D3 отказ token | Существующий catch attempt | Работает через собственный attempt metadata, но после publish | Как A |
| Связность / риск | Новый сервис около 100 строк, таблица | Высокий: конкурентная очередь producers/observers, общая для GET и каталога | Средний: логика переноса в mapper |
| Обратимость | `DROP TABLE` | Трудно отделить от общего кэша | Payload остаётся в истории |
| Новая технология | Нет | Нет | Нет |
Также отвергнуто: `TbankInstrumentSource`. FK на `TbankInstrument` требует сначала создать строку инструмента, а она служит payload общей metadata и каталога: upsert перезаписал бы `metadata` для GET.
Условие пересмотра A: если появится второй потребитель сектора или бренда (например, каталог), таблицу стоит сделать общим справочником с собственным владельцем, оформив это через ADR.
## Risks / Trade-offs
| Риск / компромисс | Влияние | Мера |
| --- | --- | --- |
| Квоты T-Bank при первом обходе и при постоянных отказах (Q-F3, qa-analysis Gaps) | Шаг Portfolio длиннее; queue full даёт исход «иная ошибка» | Последовательный цикл, дедупликация; ceiling — `WORK_WAIT_BOUND_MS`. Квоты и полнота данных (Q-D1, Q-D2, Q-F3) проверяются на реальном токене в QA до G4 — решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) |
| `fetchedAt` = момент ответа, поэтому фактический период ≈ 25 ч (обход раз в час, ответ позже старта) | Обновление на час позже «суток» | Принято владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)); соответствует сценариям PVΔ «Обновление сектора и эмитента» буквально |
| Шаг до publish удлиняет время до нового Snapshot | Финансовые данные публикуются на секунды–минуты позже | Только когда данные нужно получать; иначе одно чтение из БД |
| Отказ token в классификации блокирует Snapshot | Намеренно, D3 | — |
| Карточка позиции считает долю через `Number` (`formatShare`), вкладка — точно | В граничных случаях (x,x5) показ может расходиться на 0,1 п.п. | **Принятый компромисс:** карточка не меняется (вне scope), вкладка следует точному расчёту PC «Расчёт долей»/PC «Показ долей»; Specs не меняются |
| Ключ `metadataKey` различает «UID» и «UID+FIGI» | Возможны дубли строк одного инструмента | Безвредно: дополнительный RPC раз в сутки |
| `government` появится и в каталогах | Подпись вместо сырого кода | Решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); корректно по таблице PC «Русские подписи известных секторов» |
**Решения владельца ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)):** период ≈ 25 ч приемлем (B3); квоты и полнота данных на реальном токене проверяются в QA до G4; «Государственный сектор» — в общем словаре и в каталогах (F6). Открытых findings для Specs и analysis нет: переход UID→FIGI после NOT_FOUND — design-решение B4, расхождение float/точно — принятый компромисс.
## ADR Candidates
- **ADR 022 «Хранимая классификация позиций (sector, brand) с суточным обновлением в часовом обходе»**, статус `Accepted` (решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)). Пишется в docs-задаче после реализации, как AS-IS (`docs/adr/022-stored-instrument-classification.md`, по analysis/docs-impact.md «ADR Decision»). Дополняет ADR 019 исключением, которое ввела PVΔ «Свежесть и fallback полного Portfolio Snapshot». Альтернативы — B и C из Solution Assessment. Последствия: первый долговременный справочник T-Bank, условие пересмотра при втором потребителе.
- Opt-in NOT_FOUND в `TbankClient` — локальное решение, не ADR.