# 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 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` выводит только одну строку ``. Поэтому центр — 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`, которое уходит в ``). Отсюда задаются обводка и `fill="url(#…)"` для штриховки «Прочее» и «не указан». Узоры (``) лежат в отдельном скрытом `` в той же панели: ссылки `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.