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

This commit is contained in:
2026-10-31 13:42:27 +03:00
parent b9ec11f5ba
commit e3cc9c8d25
23 changed files with 3441 additions and 0 deletions
@@ -0,0 +1,210 @@
# Системный анализ
## Контекст и статус
Цель — передать в Portfolio сектор инструмента и бренд его актива, чтобы frontend строил четыре разреза концентрации одного счёта без конвертации валют. Основание: `specs/openspec/changes/account-top-positions/proposal.md:11-18,60-63`; Issue [main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48). Рабочая ветка specs — `feature/48-account-top-positions`.
Документ приведён в соответствие с решениями владельца [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081) и [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). G2 по комплекту Specs, system-analysis, data-mapping и failure-model approved (`process-record.md`, Gate Log, последняя строка G2). Технологии, внутренние компоненты и форма хранения здесь не проектируются.
Обозначения. **PC** — `specs/openspec/changes/account-top-positions/specs/portfolio-concentration/spec.md`; **PVΔ** — `specs/openspec/changes/account-top-positions/specs/portfolio-view/spec.md`. Requirement указываются по названию заголовка: `PC «Название»`, `PVΔ «Название»`. Пути с `backend/`, `frontend/`, `docs/`, `specs/` отсчитываются от workspace WealthLedger.
## Покрытие требований (Requirement Coverage)
Покрыты все 32 Requirement: 28 в PC и 4 в PVΔ (2 MODIFIED, 2 ADDED). Заголовки в первом столбце приведены дословно (проверено сопоставлением с файлами). Требования «Последнее известное значение сектора и эмитента» и «Ошибки получения сектора и эмитента» из Specs удалены: их содержание вошло в PVΔ «Свежесть и fallback полного Portfolio Snapshot» и PVΔ «Результат получения сектора и эмитента».
| Requirement | Flow / контракт | Модуль |
| --- | --- | --- |
| PC `Вкладка «Концентрация» страницы счёта` | F3: только страница одного счёта; `/all` вкладки не получает; вкладка по умолчанию прежняя | Hub |
| PC `Вкладка в состояниях страницы` | F2 → F3: нет вкладки при загрузке, ошибке, недоступности и пустом Portfolio; есть при ≥1 позиции или денежном остатке; только остатки → пустой разрез | Hub; failure-model F12 |
| PC `Переключение разрезов` | F3: четыре разреза одного ответа, один видимый; T-Bank не вызывается | Hub |
| PC `Начальное состояние вкладки` | F3: разрез и валюта сохраняются, пока User на странице; сброс при новом заходе, переключении счёта и перезагрузке; на сервер не передаётся | Hub |
| PC `Состав разрезов` | F3: Секторы — STOCK/BOND/FUND, Эмитенты — STOCK/BOND; `issuer` у FUND приходит в API, разрез его отбрасывает | data-mapping: «Применимость по типу», I3 |
| PC `Названия элементов` | F2 → F3: displayName, подпись sector, `issuer.name`; группировка эмитента по `issuer.id` | data-mapping |
| PC `Название эмитента при разных названиях бренда` | F3: имя — `issuer.name` позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавитному порядку PC «Порядок элементов с равной суммой»; выбор на клиенте | data-mapping: I8 |
| PC `Позиции, учитываемые в разрезе` | F3: только `currentValue` > 0 в выбранной валюте | data-mapping: I4 |
| PC `Сноска о не учтённых позициях` | F3: null в любой валюте и отрицательные в выбранной | data-mapping: I12 |
| PC `Одна валюта за раз` | F3: одна валюта на все разрезы, без конвертации | data-mapping: I11 |
| PC `Доступные валюты вкладки` | F3: ненулевые оценки STOCK/BOND/FUND, ISO-порядок; при одной валюте или без валют выбора нет | data-mapping: I11 |
| PC `Независимый выбор валюты вкладки` | F3: локальный выбор; пропавшая валюта → первая в списке | Hub |
| PC `Топ-5 и строка «Прочее»` | F3: до 6 элементов — все строки; от 7 — пять и «Прочее» с числом остальных | data-mapping: I7 |
| PC `Раскрытие строки «Прочее»` | F3: остаток уже входит в ответ; нового запроса нет | Hub |
| PC `Порядок элементов с равной суммой` | F3: название без регистра: цифры, латиница, кириллица, затем порядок в Portfolio | data-mapping: I8 |
| PC `Расчёт долей` | F3: знаменатель — сумма учитываемых позиций разреза | data-mapping: I5, I9 |
| PC `Показ долей` | F3: формат карточек позиций, «< 0,1 %» | data-mapping |
| PC `Суммарная доля топ-5` | F3: метрика только при ≥6 элементах | data-mapping: I7 |
| PC `Группы «Сектор не указан» и «Эмитент не указан»` | F2 → F3: `null` образует равноправный элемент | data-mapping: I6; failure-model F06 |
| PC `Подписи секторов` | F3: известный код → подпись, иначе код как получен | data-mapping |
| PC `Русские подписи известных секторов` | F3: таблица подписей | data-mapping |
| PC `Диаграмма распределения разреза` | F3: сегменты совпадают со списком; ≤6 элементов — по сегменту, ≥7 — пять и «Прочее» | data-mapping: I10 |
| PC `Центр диаграммы` | F3: сумма разреза и подпись «Топ-5 · N %» при показе метрики | data-mapping: I10 |
| PC `Строки без переходов` | F3: строки без переходов; в «Эмитентах» без логотипов; `logoName` остаётся в API | Hub |
| PC `Доступность диаграммы без цвета` | F3: текстовый эквивалент и сопоставление сегмента со строкой | Hub; `ux-design.md` |
| PC `Пустое состояние разреза` | F3: нет учтённых элементов — пустое состояние без доли и диаграммы | data-mapping: I14 |
| PC `Предупреждение о неполных данных` | F2 → F3: `meta` обозначает неполноту — «Данные неполные» только на вкладке над разрезом; страница его не дублирует; позиции без оценки и отсутствие sector/issuer предупреждения не вызывают | failure-model F14; заметка для design ниже |
| PC `Состояния Portfolio на вкладке «Концентрация»` | F2 → F3: состояния как на «Позициях»; отсутствие sector/issuer не скрывает вкладку и не меняет успешность и свежесть | failure-model F12, F14 |
| PVΔ `Нормализованный состав Portfolio` (MODIFIED) | F1 → F2: аддитивные `sector`, `issuer`; значения только у STOCK/BOND/FUND, включая `issuer` фонда; обрезка пробелов; остальные типы `null`; UID → FIGI; исключение — отказ token | Hub: контракт; data-mapping; failure-model |
| PVΔ `Свежесть и fallback полного Portfolio Snapshot` (MODIFIED) | F1, F2: два исключения — сектор и бренд хранятся как последнее известное значение (GET читает хранимое, T-Bank не вызывает); отказ token при получении обрабатывается как при обходе Portfolio | Hub; failure-model F08, F11 |
| PVΔ `Обновление сектора и эмитента` (ADDED) | F1: обновление при первом обходе, начавшемся не ранее чем через 24 ч после последнего успешного получения; пока значение не получено — при каждом обходе | failure-model: «Таймауты и повторы» |
| PVΔ `Результат получения сектора и эмитента` (ADDED) | F1: четыре исхода определяют поле и отсчёт 24 ч; неуспешное получение не мешает обновлению Portfolio | data-mapping; failure-model F02–F05 |
## Системный контекст и границы
Участники: T-Bank Invest API → backend часовой синхронизации → сохранённый Portfolio Snapshot → backend HTTP API → авторизованный frontend. T-Bank владеет исходной классификацией и брендами, WealthLedger — нормализацией и представлением. Подтверждено `docs/architecture/context.md:9-38`, `docs/architecture/containers.md:9-33`, `docs/integrations/tbank.md:3-21`.
Сектор и бренд получает сервер в часовом обходе, не браузер и не открытие вкладки. Обычный GET и его повтор не обращаются к T-Bank (`specs/openspec/specs/tbank-data-sync/spec.md:9-14`; PVΔ «Свежесть и fallback полного Portfolio Snapshot», сценарий «Сектор и бренд читаются из хранимого значения»). MOEX, НКР и CDN логотипов не являются источниками sector/issuer.
**Владелец расчёта разрезов — frontend над полным ответом Portfolio.** Подтверждено: Change Profile — «вкладка считается по уже загруженному Portfolio» (`specs/openspec/changes/account-top-positions/process-record.md:16`), impact в `proposal.md:60`. Specs закрепляют клиентский расчёт: имя эмитента выбирается по позиции с наибольшим `currentValue` в выбранной валюте (PC «Название эмитента при разных названиях бренда»), а валюта и учитываемость зависят от выбора пользователя (PC «Позиции, учитываемые в разрезе», PC «Одна валюта за раз»). Серверный агрегат такой выбор не воспроизвёл бы без параметров запроса, которых контракт не вводит. Backend-агрегаты, endpoint топ-5 и обращение браузера к каталогу не требуются.
## As-Is / To-Be
### As-Is: фактический путь
1. Часовой планировщик независимо от экрана обновляет membership и Portfolio счетов (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:176-209`). Интервал и отсутствие перекрытий — `specs/openspec/specs/hourly-investment-sync/spec.md:8-21`.
2. Для счёта параллельно запрашиваются GetPortfolio и GetPositions; после нормализации полный Snapshot публикуется, и только затем запрашивается обновление metadata позиций (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`). Регистрация metadata-запроса не ждёт внешнего lookup (`backend/src/integrations/tbank/instrument-metadata.service.ts:63-105`).
3. Snapshot хранится одним JSON payload; публикация транзакционна и ограждена connection revision, attempt и доступностью счёта (`backend/prisma/schema.prisma:60-70`; `backend/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts:116-147`).
4. Mapper считает `currentValue` из quantity × currentPrice и заполняет `logoName` из metadata (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31,63-99`). Это прецедент необязательного обогащения.
5. GET читает сохранённый Snapshot и дополняет позиции сохранённой metadata без обращения к T-Bank (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:55-105,122-164`); время финансового Snapshot при этом не меняется.
6. Общая metadata — GetInstrumentBy по UID, затем по FIGI. `Instrument` содержит `asset_uid`, но не `sector`; его `brand` — `BrandData` (логотип и цвета), а не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/proto/contracts/instruments.proto:939-998`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Локальный `TbankInstrument` не объявляет `assetUid` и `sector` (`backend/src/integrations/tbank/tbank.client.ts:91-99`).
7. Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; это отдельный источник, а не готовое обогащение Portfolio (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:335-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23`).
8. **Расхождения AS-IS с PVΔ «Обновление сектора и эмитента» и PVΔ «Результат получения сектора и эмитента», которые должна закрыть реализация.** (а) Общая metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`); норма требует срок от последнего успешного получения и четыре исхода. (б) Клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`); норма относит «не найдено» к исходу «значения нет». Это требования к реализации, а не выбор между вариантами.
9. Frontend берёт Portfolio из generated paths существующего GET (`frontend/src/entities/portfolio/api/usePortfolio.ts:1-28`). Текущие DTO и опубликованный snapshot новых полей не содержат (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`; `frontend/src/shared/api/spec/openapi.json:6426-6519`; `frontend/src/shared/api/generated/schema.d.ts:1059-1085`).
10. Общий справочник инструментов в AS-IS задокументирован как онлайн, без долговременного хранения (`docs/architecture/cross-cutting/data-freshness.md:9-12`, ADR 019); PVΔ «Свежесть и fallback полного Portfolio Snapshot» вводит исключение для сектора и бренда.
### To-Be: системные обязательства
- Каждая позиция содержит `sector` и `issuer`; значения только у STOCK, BOND, FUND, у остальных типов `null` (PVΔ «Нормализованный состав Portfolio»).
- Получение — в часовом обходе: при первом обходе, начавшемся не ранее чем через 24 часа после последнего успешного получения; пока значение не получено — при каждом обходе (PVΔ «Обновление сектора и эмитента»). Поле и отсчёт 24 часов после каждого получения определяет таблица исходов (PVΔ «Результат получения сектора и эмитента»); здесь она не пересказывается.
- Сектор и бренд хранятся как последнее известное значение; GET читает хранимое (PVΔ «Свежесть и fallback полного Portfolio Snapshot»).
- Отказ token при получении обрабатывается как отказ token в обходе Portfolio (PVΔ «Свежесть и fallback полного Portfolio Snapshot»); иные ошибки на обновление Portfolio не влияют (PVΔ «Результат получения сектора и эмитента»).
- Frontend получает полный список позиций и сам вычисляет разрезы; Portfolio не сортируется и не объединяется (PVΔ «Нормализованный состав Portfolio»).
Способ получения (типизированный запрос или ранее сохранённые данные), форма хранения времени успешного получения и порядок вызовов относительно публикации — design; нормы Specs от них не зависят, кроме того, что после отказа token подключение недействительно.
## Анализ влияния
| Область | Изменение / обязательство | Не меняется |
| --- | --- | --- |
| T-Bank → backend | Типизированный sector и asset → brand только для STOCK/BOND/FUND; источники-кандидаты в data-mapping | GetPortfolio/GetPositions, права подключения |
| Часовой обход | Дополнительные чтения по инструментам и активам по сроку PVΔ «Обновление сектора и эмитента» | Интервал 60 минут, запрет перекрытия |
| Сохранённый Snapshot и состояние получения | Новые nullable поля позиций; хранение последнего известного значения и времени последнего успешного получения (PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента») | Количество, деньги, порядок, `fetchedAt` финансового наблюдения |
| Backend → frontend | Аддитивные поля `PortfolioPositionDto` в `/api/v1` | Envelope, параметры, прежние статусы |
| Подключение | Отказ token при получении сектора/бренда делает подключение недействительным как при обходе Portfolio | Модель доступа |
| Frontend | Четыре вычисляемых разреза над полным Portfolio; единственное сообщение о неполноте на вкладке (PC «Предупреждение о неполных данных») | Сводка, остальные вкладки |
| Режим «Все счета» | Общая DTO получает те же поля; вкладки и расчёта нет | Агрегирование счетов |
| API artifacts | backend OpenAPI → переданный snapshot → generated types | Автономность репозиториев |
| Docs | Темы Portfolio и T-Bank, глоссарий, исключение из онлайн-справочника в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019 — по docs-impact | В этой задаче docs не редактируются |
Общая DTO для двух GET подтверждена `backend/src/modules/investments/portfolio/presentation/portfolio.controller.ts:19-31`. Подробности — [data-mapping](data-mapping.md) и [failure-model](failure-model.md).
### Заметка для design: дублирование предупреждения о неполноте
PC «Предупреждение о неполных данных» требует одно сообщение о неполноте: «Данные неполные» на вкладке, страница его не дублирует. В AS-IS страница уже показывает собственный Alert при неполноте:
- `frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:77-79` — `<Alert role="status">{t("investment.partial")}</Alert>` при `props.partial`; признак вычисляется как `portfolio.meta?.completeness === "incomplete"` (`frontend/src/pages/account/ui/AccountPage/AccountPage.vm.ts:124,130`);
- Alert выводится над `<Tabs>` (`AccountPageLoaded.view.tsx:108-112`, неконтролируемый `defaultValue="positions"`), поэтому виден при любой вкладке, включая «Концентрация»;
- текст Alert — «Период загружен не полностью» (`frontend/src/shared/i18n/locales/ru.ts:423`), не «Данные неполные».
Следствие: при открытой вкладке «Концентрация» и неполном Portfolio страничный Alert не должен показываться (иначе на странице два сообщения). Содержание других вкладок не меняется (PC «Вкладка «Концентрация» страницы счёта»). Состояние активной вкладки сейчас внутри `Tabs` и странице недоступно; как это обеспечить — design.
## Системы и ответственность
- T-Bank: инструмент, его сектор, связь с активом, идентичность и название бренда; допустимость token.
- Backend sync: полный финансовый результат; получение sector/brand по PVΔ «Обновление сектора и эмитента» и PVΔ «Результат получения сектора и эмитента»; хранение последнего известного значения; обработка отказа token.
- Backend HTTP: проверка доступа, выдача Portfolio с новым shape, явные `null` для старых Snapshot.
- Frontend: вычисление разрезов, единый набор сумм для списка и диаграммы, выбор имени эмитента по PC «Название эмитента при разных названиях бренда», единственное предупреждение о неполноте.
- Change Owner: принимает остаточные факты данных (Q-D1, Q-D2, Q-F3) как результат проверки при реализации; решений по ним не требуется.
## Владение данными
| Данные | Источник истины | Пишут | Читают |
| --- | --- | --- | --- |
| quantity, price, cash | T-Bank финансовые ответы | Backend публикует Snapshot | GET, frontend |
| `sector` (STOCK/BOND/FUND) | `sector` Share/Bond/Etf в T-Bank, обрезанный по краям | Backend; хранимое последнее известное значение | Frontend: группирует по точному коду |
| `issuer` (STOCK/BOND/FUND) | `AssetFull.brand.uid`/`name` в T-Bank, обрезанные по краям | Backend; пара целиком либо `null` | Frontend: группирует по `id`, имя по PC «Название эмитента при разных названиях бренда» |
| Время последнего успешного получения sector/brand | Backend | Backend | Backend: условие PVΔ «Обновление сектора и эмитента» |
| lastSuccessfulAt, isStale, `meta` | Подтверждённое финансовое наблюдение и состояние ресурса | Backend sync / local read | Frontend |
| Разрезы, суммы, доли, топ-5, имя эмитента | Производные от одного ответа Portfolio | Frontend, только вычисляет | Список и диаграмма |
| Русские подписи секторов | Таблица PC «Русские подписи известных секторов» | Frontend | Пользователь |
## Потоки взаимодействий
**F1 — часовой обмен.** Backend проверяет подключение и доступные счета → GetPortfolio + GetPositions → полный финансовый Snapshot. Для позиций STOCK/BOND/FUND, у которых значение не получено либо наступил срок PVΔ «Обновление сектора и эмитента», запрашиваются сектор и бренд актива; результат применяется по PVΔ «Результат получения сектора и эмитента»; отказ token делает подключение недействительным (PVΔ «Свежесть и fallback полного Portfolio Snapshot»). Прочие сбои получения на финансовый результат не влияют.
**F2 — локальная выдача.** Авторизованный GET проверяет membership и connection → читает Snapshot и хранимые значения → `{data, meta}`. Для старого JSON без новых ключей возвращаются явные `null`; GET не обращается к T-Bank (PVΔ «Свежесть и fallback полного Portfolio Snapshot»). `data: null` — отсутствие полного снимка, а не пустая концентрация (`docs/domains/portfolio/flows/portfolio-loading.md:10-21`).
**F3 — клиентская проекция.** Из полного Portfolio frontend определяет список валют, выбирает разрез, отбирает позиции, агрегирует, ранжирует, выбирает имя эмитента и рассчитывает доли. Переключение разрезов и раскрытие «Прочее» новых запросов не требуют.
Диаграммы в отдельных файлах не создаются: работа ограничена тремя Markdown-файлами; три потока выше передают границы без внутреннего устройства.
## Контракты
| Контракт | Producer → Consumer | Версия / artifact | Совместимость | Опубликованный источник |
| --- | --- | --- | --- | --- |
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio`, `data.positions[]` | Backend → frontend | API v1; `PortfolioPositionDto` + delta ниже | additive | `frontend/src/shared/api/spec/openapi.json:6426-6519` — предоставленная версия |
| GET `/api/v1/brokerage-accounts/all/portfolio`, общий shape позиции | Backend → существующие потребители | Тот же DTO; вкладка не применяется | additive | `backend/src/modules/investments/portfolio/presentation/portfolio.controller.ts:19-31` |
| Instrument / Asset lookup | T-Bank → backend | `tinkoff.public.invest.api.contract.v1`, локальный `instruments.proto` | Внешний контракт не меняется; расширяется используемая часть | Источники-кандидаты в data-mapping |
### Семантическая delta `PortfolioPositionDto`
| Поле | Присутствие | Тип / ограничение | Значение |
| --- | --- | --- | --- |
| `sector` | Обязательно в каждой позиции | string или null | Код T-Bank без перекодирования, с обрезанными краями; только у STOCK/BOND/FUND; иначе `null` |
| `issuer` | Обязательно в каждой позиции | object или null | `{id, name}` только у STOCK/BOND/FUND, включая FUND; иначе `null` |
| `issuer.id` | Внутри непустого `issuer` | Непустая строка после обрезки: `brand.uid` | Не `asset_uid` и не UID инструмента |
| `issuer.name` | Внутри непустого `issuer` | Непустая строка после обрезки: `brand.name` | Не `brand.company`, не название актива |
Пример позиции с данными: `{"sector":"financial","issuer":{"id":"brand-uid-example","name":"Сбер"}}`; идентификатор условный. Без данных: `{"sector":null,"issuer":null}`. Поля независимы. Значение из одних пробелов считается отсутствующим (PVΔ «Нормализованный состав Portfolio»).
Изменение добавляет только response-поля: запросы, Money, маршруты, статусы и версия API остаются. Текущий snapshot — OpenAPI **3.0.0** (`frontend/src/shared/api/spec/openapi.json:2`); синтаксис 3.1 молча не вводится. Таблица — семантическая delta, не полный машиночитаемый target OpenAPI. Протокол требует target-контракт в `analysis/contracts/` до реализации (`specs/openspec/schemas/wealth-ledger-sdd-lab/schema.yaml:383-393`); он не создан, так как работа ограничена тремя файлами. Для G2 нужен отдельный разрешённый шаг публикации и проверки target-контракта. После реализации backend-generated OpenAPI должен семантически совпасть с ним до G4.
### Публикация и получение API artifact
Backend генерирует и проверяет OpenAPI; потребителю явно передаётся новый artifact. Frontend обновляет versioned `src/shared/api/spec/openapi.json`, локально генерирует `src/shared/api/generated/schema.d.ts` и проверяет соответствие. Generated types вручную не правятся; frontend не читает backend checkout, runtime не требует Swagger. Основания: `specs/openspec/specs/api-contract-generation/spec.md:10-35`; `docs/architecture/http-api.md:5-22`; `frontend/.ai/conventions/data.md:15-34`.
## Семантика ошибок
Новых бизнес-ошибок HTTP из-за sector/issuer нет, кроме отказа token. Результат получения определяет PVΔ «Результат получения сектора и эмитента»; `200` с группами «не указан» не означает сбой Snapshot (PVΔ «Нормализованный состав Portfolio»; PC «Группы «Сектор не указан» и «Эмитент не указан»», PC «Состояния Portfolio на вкладке «Концентрация»»).
Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: `400 INVALID_TBANK_TOKEN` без fallback Snapshot (PVΔ «Свежесть и fallback полного Portfolio Snapshot», сценарий «Отказ token при получении бренда»; `specs/openspec/specs/portfolio-view/spec.md:113-115`). Это соответствует текущему клиенту, сводящему PERMISSION_DENIED и UNAUTHENTICATED к InvalidToken (`backend/src/integrations/tbank/tbank.client.ts:571-575`).
Прочие ошибки доступа: нет подключения — `409 TBANK_NOT_CONNECTED`, недоступный счёт — `404` (`docs/domains/portfolio/flows/portfolio-loading.md:24-35`).
## Совместимость и миграция
1. **Старый потребитель → новый backend:** аддитивные JSON-поля не меняют существующий shape; известный потребитель — generated paths (`frontend/src/entities/portfolio/api/usePortfolio.ts:7-16`).
2. **Новый backend → старый JSON Snapshot:** отсутствующие ключи выдаются как `null`; Snapshot не пересоздаётся, `lastSuccessfulAt` не меняется. Значения появляются после обхода, получившего их по PVΔ «Обновление сектора и эмитента». Текущий spread payload этого сам не делает (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:88-95`); механизм — design.
3. **Новый frontend → старый backend или Client Cached Snapshot:** отсутствие ключей трактуется как неизвестные значения. Использование новых полей возможно после получения provider-first artifact.
4. **Свежесть:** в обходах раньше срока значение не меняется (PVΔ «Обновление сектора и эмитента»); sector/brand не влияют на `isStale` (PVΔ «Нормализованный состав Portfolio»).
5. **`/all`:** общий DTO проверяется, расчёта концентрации нет. Ломающих изменений для существующих потребителей нет.
## Подробные модули
- [data-mapping.md](data-mapping.md): источники полей, типы позиций, нормализация, инварианты, риски классификации.
- [failure-model.md](failure-model.md): сценарии отказов со ссылками на нормы, лимиты, повторы, восстановление.
- Отдельные nfr и observability не выбраны: нагрузка и частичное обогащение рассматриваются в failure-model без выдуманных SLA (`process-record.md:16-17`).
- `ux-design.md` — представление вкладки; в этом комплекте не дублируется.
## Открытые вопросы
### Закрыто решениями владельца
| Вопрос | Решение | Где в Specs |
| --- | --- | --- |
| `issuer` у фондов | Заполняется у STOCK/BOND/FUND; разрез «Эмитенты» фонды отбрасывает ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) | PVΔ «Нормализованный состав Portfolio»; PC «Состав разрезов» |
| Имя при разных названиях одного `issuer.id` | Название позиции с наибольшим `currentValue`; при равных — по алфавиту (3081, [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)) | PC «Название эмитента при разных названиях бренда» |
| Пробелы | Края обрезаются; значение из одних пробелов отсутствует (3081) | PVΔ «Нормализованный состав Portfolio» |
| Срок и поведение при ошибке | Раз в сутки от последнего успешного получения; исходы по таблице (3081, 3082) | PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента» |
| Отказ token | Как отказ token в обходе Portfolio; прочие ошибки не мешают обновлению Portfolio (3081, 3082) | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Результат получения сектора и эмитента» |
| Хранение сектора и бренда вопреки «Свежесть и fallback» | Исключение для сектора и бренда (3082) | PVΔ «Свежесть и fallback полного Portfolio Snapshot» |
| «Не найдено» от T-Bank | Как «значения нет» (3082) | PVΔ «Результат получения сектора и эмитента» |
| Предупреждение о неполноте | Только на вкладке (3082) | PC «Предупреждение о неполных данных» |
### Остаётся неизвестным
Это факты данных T-Bank; они проверяются при реализации и решения владельца не требуют.
| ID | Что неизвестно | Чем проверяется |
| --- | --- | --- |
| Q-D1 | Реальное покрытие STOCK/BOND/FUND брендом актива; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | См. data-mapping: однократный read-only прогон на подключении владельца с агрегированной сводкой |
| Q-D2 | Какие значения `sector` фактически отдаются для фондов и государственных облигаций | См. data-mapping: тот же прогон, список различных значений со счётчиками |
| Q-F3 | Фактические квоты методов T-Bank, число различных инструментов и активов, вид ответа «не найдено» | См. failure-model: официальные лимиты Invest API, измерение числа вызовов в обходе, запрос с несуществующим `asset_uid` |