Files
specs/openspec/changes/archive/2026-11-02-account-top-positions/design.md
T
codex-bot 3821d9ed58
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 14s
refactor: group canonical specs by domain and archive account-top-positions (#74)
2026-11-02 21:50:49 +03:00

46 KiB
Raw Blame History

Engineering Design

Design описывает, как backend и frontend реализуют утверждённые Specs и модули analysis для change account-top-positions. Обозначения PC, PVΔ — как в system-analysis.md; Requirement указываются по названию заголовка. Пути с backend/ и frontend/ отсчитываются от workspace; база кода — backend d37fa2c, frontend 71126c9. Design и ux-design.md образуют один Design package с одним approval на G3. UX здесь не пересказывается: этот документ описывает только инженерную реализацию.

Inputs

  • Specs (G2 approved): specs/portfolio/portfolio-concentration/spec.md (28 Requirement), specs/portfolio/portfolio-view/spec.md (PVΔ «Нормализованный состав Portfolio», PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента»).
  • Analysis (G2 approved): system-analysis.md (контракты, семантическая delta PortfolioPositionDto, заметка о дублировании Alert), data-mapping.md (M1–M3, источники-кандидаты в T-Bank, инварианты I1–I8), failure-model.md (классификация исходов, F01–F16).
  • Design package (G3): ux-design.md, Visual Package v2 (visuals/*.svg, SHA-256 — в ux-design «Materials»).
  • Параллельно с design: 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). Отвергнуто: брать время из 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.

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:

// 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
fetchedAt = момент ответа, поэтому фактический период ≈ 25 ч (обход раз в час, ответ позже старта) Обновление на час позже «суток» Принято владельцем (3084); соответствует сценариям PVΔ «Обновление сектора и эмитента» буквально
Шаг до publish удлиняет время до нового Snapshot Финансовые данные публикуются на секунды–минуты позже Только когда данные нужно получать; иначе одно чтение из БД
Отказ token в классификации блокирует Snapshot Намеренно, D3 —
Карточка позиции считает долю через Number (formatShare), вкладка — точно В граничных случаях (x,x5) показ может расходиться на 0,1 п.п. Принятый компромисс: карточка не меняется (вне scope), вкладка следует точному расчёту PC «Расчёт долей»/PC «Показ долей»; Specs не меняются
Ключ metadataKey различает «UID» и «UID+FIGI» Возможны дубли строк одного инструмента Безвредно: дополнительный RPC раз в сутки
government появится и в каталогах Подпись вместо сырого кода Решение владельца 3084; корректно по таблице PC «Русские подписи известных секторов»

Решения владельца (3084): период ≈ 25 ч приемлем (B3); квоты и полнота данных на реальном токене проверяются в QA до G4; «Государственный сектор» — в общем словаре и в каталогах (F6). Открытых findings для Specs и analysis нет: переход UID→FIGI после NOT_FOUND — design-решение B4, расхождение float/точно — принятый компромисс.

ADR Candidates

  • ADR 022 «Хранимая классификация позиций (sector, brand) с суточным обновлением в часовом обходе», статус Accepted (решение владельца 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.