46 KiB
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-concentration/spec.md(28 Requirement),specs/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-контракт — таблица «Семантическая deltaPortfolioPositionDto» в 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):
- GetPortfolio и GetPositions.
normalize.classification.refresh(token, positions, walkStartedAt)— новый шаг.mapPortfolioзаписываетsector/issuerиз возвращённой Map в Snapshot.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_MS15 мин (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на rootBox.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).dataRevisionSnapshot и так растёт при каждой публикации.
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
Порядок выката:
- Backup БД и ключа шифрования по ADR 015.
- Backend: миграция
CREATE TABLEи код. Новые поля сразу в DTO (nullдо первого обхода). - Публикация artifact OpenAPI.
- 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увидит применённую миграцию, которой нет в его каталоге. По документации Prismamigrate deployприменяет только недостающие локальные миграции и не сверяет историю, аmigrate statusсообщает о ней как о «applied but missing locally»; это не подтверждено на версии проекта и проверяется в task 3.4. Snapshot с полямиsector/issuerчитается старым кодом без ошибок: поля лишние, ответ проходит через сериализацию DTO. На G4 это нужно проверить: whitelist сериализации в backend не найден, поэтому старый код может отдавать лишние поля как есть, и это безвредно.
- Фактический запуск: production image стартует
- БД.
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.