Compare commits
3
Commits
a2d2df3b5a
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f358545f | ||
|
|
c66a5cd962 | ||
|
|
b9ec11f5ba |
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.6...v0.2.0) (2026-11-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add experimental wealth-ledger-sdd-lab OpenSpec schema ([#61](https://git.personal.ksv741.ru/WealthLedger/specs/issues/61)) ([322ddf6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/322ddf6a7f9b3309a875e17ee37a574c8decb1b0))
|
||||||
|
|
||||||
|
### Specifications
|
||||||
|
|
||||||
|
* archive group-account-rows-by-instrument and sync canonical specs (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/specs/issues/47)) ([#65](https://git.personal.ksv741.ru/WealthLedger/specs/issues/65)) ([a2d2df3](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a2d2df3b5a7fc7f72bdd82ab42bb0ae25debfffe))
|
||||||
|
* plan grouping of all-account rows by instrument (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/specs/issues/47)) ([#64](https://git.personal.ksv741.ru/WealthLedger/specs/issues/64)) ([f3a6b78](https://git.personal.ksv741.ru/WealthLedger/specs/commit/f3a6b78461408083f0ad04bdfe64634c09f53be2))
|
||||||
|
* record account-top-positions planning and implementation (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/specs/issues/48)) ([#69](https://git.personal.ksv741.ru/WealthLedger/specs/issues/69)) ([c66a5cd](https://git.personal.ksv741.ru/WealthLedger/specs/commit/c66a5cd962fd265c3365e8c396a7958ba307bac0))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* make sdd-lab review-driven (WealthLedger/specs[#66](https://git.personal.ksv741.ru/WealthLedger/specs/issues/66)) ([b9ec11f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b9ec11f5ba193dc851c79908c5ed8accc34e2e54))
|
||||||
|
|
||||||
## [0.1.6](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.5...v0.1.6) (2026-10-24)
|
## [0.1.6](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.5...v0.1.6) (2026-10-24)
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: wealth-ledger-sdd-lab
|
||||||
|
created: 2026-10-30
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Маппинг данных
|
||||||
|
|
||||||
|
## Источник и цель
|
||||||
|
|
||||||
|
Документ не выбирает внутренний способ загрузки и хранения. Обозначения **PC**, **PVΔ** и правило нумерации строк — в [system-analysis.md](system-analysis.md). Requirements: `specs/openspec/changes/account-top-positions/specs/portfolio-concentration/spec.md` (28) и `specs/openspec/changes/account-top-positions/specs/portfolio-view/spec.md` (4). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). Исходы получения (когда поле заменяется, сохраняется или становится `null`, когда отсчёт 24 часов начинается заново) определяет таблица PVΔ:156; здесь она не пересказывается.
|
||||||
|
|
||||||
|
| Mapping | Источник (владелец) | Цель (владелец) | Направление | Trigger |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| M1: сектор | `sector` Share / Bond / Etf (T-Bank) | `positions[].sector` (backend) | T-Bank → хранимое значение → HTTP Portfolio | Часовой обход по условию PVΔ:141 |
|
||||||
|
| M2: эмитент | `asset_uid` инструмента → `AssetFull.brand.uid` / `name` (T-Bank) | `positions[].issuer` (backend) | T-Bank → хранимое значение → HTTP Portfolio | То же |
|
||||||
|
| M3: проекция | Полный ответ Portfolio (backend — владелец API) | Разрезы, суммы, доли, подписи, имя эмитента (frontend) | HTTP Portfolio → вычисляемая проекция | Получение ответа, смена разреза или валюты; обращений к T-Bank нет |
|
||||||
|
|
||||||
|
Финансовый источник не меняется: количество и цена из GetPortfolio, деньги из GetPositions; sector и issuer деньги не пересчитывают (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`; `backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:33-55,63-98`).
|
||||||
|
|
||||||
|
### Источники-кандидаты в T-Bank
|
||||||
|
|
||||||
|
Выбор между точечным запросом и пригодными ранее сохранёнными данными — design: Specs его не определяют. Сопоставление идёт по instrument UID, затем по FIGI (PVΔ:3); эмитент по названию, тикеру или ISIN не определяется.
|
||||||
|
|
||||||
|
| Потребность | Кандидат и поля ответа | Подтверждение / ограничения |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| STOCK: sector, asset UID | `ShareBy(InstrumentRequest)` → `Share.sector`, `Share.asset_uid` | RPC `backend/src/integrations/tbank/proto/contracts/instruments.proto:65`; поля `:760,:776` |
|
||||||
|
| BOND: sector, asset UID | `BondBy(InstrumentRequest)` → `Bond.sector`, `Bond.asset_uid` | RPC `instruments.proto:24`; поля `:525,:543` |
|
||||||
|
| FUND: sector, asset UID | `EtfBy(InstrumentRequest)` → `Etf.sector`, `Etf.asset_uid` | RPC `instruments.proto:42`; поля `:644,:657` |
|
||||||
|
| Общая идентичность | `GetInstrumentBy` → `Instrument.uid`, `figi`, `asset_uid` | `asset_uid` есть (`instruments.proto:971`); поля `sector` в `Instrument` нет, поэтому им одним сектор не получить |
|
||||||
|
| Бренд актива | `GetAssetBy(AssetRequest{id: asset_uid})` → `AssetFull.brand.uid`, `brand.name` | `instruments.proto:1030-1036,1051-1068,1220-1229`; `asset_uid` — ключ запроса, не значение `issuer.id` |
|
||||||
|
| Список активов | `GetAssets` → `Asset[]` | У `Asset` нет `brand` (`instruments.proto:1075-1080`); для `issuer` не подходит |
|
||||||
|
|
||||||
|
### Что уже есть в коде
|
||||||
|
|
||||||
|
- GetInstrumentBy по UID и FIGI даёт общую metadata. Его `brand` — `BrandData` (`logo_name`, цвета), не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/tbank.client.ts:293-305,493-509`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Эмитента из `logoName` или названия инструмента получить нельзя.
|
||||||
|
- Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; типы `TbankShare`/`TbankBond` их содержат (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:341-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23,48-61`). Это отдельный источник, и пользователь не должен сначала открывать карточку инструмента.
|
||||||
|
- `GetAssetBy` уже вызывается ради `description`; `brand` из ответа не передаётся (`backend/src/integrations/tbank/tbank.client.ts:420-429`; `backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:431-437`). `GetAssets` в TypeScript интеграции не используется.
|
||||||
|
- **Расхождения AS-IS с нормами, которые закрывает реализация** (это требования, а не выбор):
|
||||||
|
- запись общей metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`) — норма PVΔ:141 требует срок от последнего успешного получения;
|
||||||
|
- клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`) — по PVΔ:156 «не найдено» относится к исходу «значения нет», а не к «иной ошибке».
|
||||||
|
|
||||||
|
## Маппинг полей
|
||||||
|
|
||||||
|
В колонке «При ошибке» исход определяется таблицей PVΔ:156; «не найдено» и «значения нет» — один исход. Колонка не повторяет решений таблицы.
|
||||||
|
|
||||||
|
| Поле цели | Поля источника | Преобразование | Единица / валюта / scale | Nullability | Default | Валидация | При ошибке |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `position.sector` | `Share.sector` / `Bond.sector` / `Etf.sector` по типу STOCK / BOND / FUND | Обрезать пробелы по краям; пусто или только пробелы → «значения нет»; остальное как получено, без lowercase, перевода и классификации | Код, без валюты | string или null | `null` | Строка; ответ принадлежит запрошенному инструменту | По PVΔ:156; `issuer` и финансы не затрагиваются |
|
||||||
|
| `position.sector` у остальных типов | — | Всегда `null`, получение не нужно | — | `null` | `null` | Тип позиции из нормализации | — |
|
||||||
|
| `position.issuer.id` | `AssetFull.brand.uid` | Обрезать пробелы; перенос без перекодирования | Непрозрачный идентификатор | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки; `AssetFull.uid` совпадает с запрошенным `asset_uid` | По PVΔ:156 |
|
||||||
|
| `position.issuer.name` | `AssetFull.brand.name` | Обрезать пробелы; не `brand.company`, не `asset.name` | Текст | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки | По PVΔ:156 |
|
||||||
|
| `position.issuer` | Предыдущие две строки одного ответа | `{id, name}` только если оба пригодны, иначе «значения нет»; STOCK / BOND / FUND, включая FUND; прочие типы `null` | Объект | object или null | `null` | Частичный объект запрещён | По PVΔ:156, объект целиком |
|
||||||
|
| Время последнего успешного получения | Исход получения | Меняется и не меняется по таблице PVΔ:156; читается условием PVΔ:141; ведётся отдельно для `sector` и `issuer` (исходы двух запросов могут различаться) | Момент времени | Нет значения до первого успешного получения | Нет | — | — |
|
||||||
|
| Новые ключи старого Snapshot | Отсутствуют | Отдавать как `null` | — | как в контракте | `null` | Отличать от отсутствия полного Portfolio | Snapshot не отбрасывать |
|
||||||
|
| Название сектора | `position.sector` | Точное совпадение с PC:374 → подпись; иное непустое значение → как получено; `null` → «Сектор не указан» | Текст отображения | Название непустое | Группа «Сектор не указан» | Ключ группировки — код, не подпись | Неизвестный код не объединять с `null` |
|
||||||
|
| Название эмитента | `issuer.name` позиции с наибольшим `currentValue` среди учитываемых позиций одного `issuer.id` в выбранной валюте | Группировать по `issuer.id`; при равных наибольших значениях — меньшее название по алфавитному порядку PC:274; `null` → «Эмитент не указан» | Текст отображения | Название непустое | Группа «Эмитент не указан» | Одинаковое `name` при разных `id` не объединяет | Нет |
|
||||||
|
| Сумма элемента / разреза | `currentValue`, `type`, `sector`, `issuer.id` | Отбор → сумма по ключу; без округления | Десятичная строка Money, одна валюта | Для учтённых элементов | Пустое состояние | Не смешивать валюты | Непригодные sector/issuer меняют группу, не сумму |
|
||||||
|
| Доля элемента, метрика топ-5 | Сумма элемента, сумма разреза | Точное отношение; метрика — сумма точных долей первых пяти; округление только при показе | Доля → % | Нет при пустом разрезе | Пустое состояние | Знаменатель > 0 | Не делить на ноль, не выдавать NaN |
|
||||||
|
|
||||||
|
**Нормализация строк.** Источник правил — PVΔ:3: сектор «как определяет T-Bank, без перекодирования», края обрезаются, значение из одних пробелов отсутствует. Регистр не меняется: `energy` и `Energy` — разные коды (PC:364 запрещает объединять разные `sector`). Для `issuer.id` Specs требуют непустоту после обрезки; обрезка возвращаемого `id` следует решению 3081 «Пробелы обрезать» и нужна, чтобы идентификатор не расщеплял группу.
|
||||||
|
|
||||||
|
**Что считается «значения нет».** Таблица PVΔ:156 использует понятие, но не перечисляет признаки; mapping задаёт их для полей этого документа: нет поля `sector`, `asset_uid`, `brand` в ответе; пустая строка либо только пробелы после обрезки; пара `brand.uid` / `brand.name` непригодна. Ответ о другом инструменте или активе распознаётся несовпадением UID / FIGI / `asset_uid` с запросом (проверка UID в AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:141-160`).
|
||||||
|
|
||||||
|
**Подписи секторов.** Нормативный справочник — PC:374: consumer, energy, financial, health_care, industrials, it, materials, real_estate, telecom, utilities, government. Это только отображение; `Brand.sector` как источник не используется. `other` остаётся `other` (PC:364).
|
||||||
|
|
||||||
|
### Применимость по типу позиции
|
||||||
|
|
||||||
|
| Portfolio type | `sector` | `issuer` | Участие в разрезах |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| STOCK | `Share.sector` | `AssetFull.brand` или `null` | Акции, Секторы, Эмитенты |
|
||||||
|
| BOND | `Bond.sector` | `AssetFull.brand` или `null` | Облигации, Секторы, Эмитенты |
|
||||||
|
| FUND | `Etf.sector` | `AssetFull.brand` или `null`; **заполняется** (PVΔ:3, решение 3081) | Только Секторы; разрез «Эмитенты» фонд отбрасывает (PC:69) |
|
||||||
|
| CURRENCY, FUTURE, OPTION, STRUCTURED_NOTE, DIGITAL_FINANCIAL_ASSET, OTHER | Всегда `null` | Всегда `null` | Ни в одном из четырёх разрезов |
|
||||||
|
|
||||||
|
Типы share → STOCK, bond → BOND, etf → FUND нормализуются mapper'ом (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31`); sector и issuer тип не меняют.
|
||||||
|
|
||||||
|
## Инварианты
|
||||||
|
|
||||||
|
| ID | Правило | Защищаемое Requirement |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| I1 | Обогащение не удаляет, не объединяет и не переставляет позиции; одинаковый FIGI не означает дубликат | PVΔ:3 |
|
||||||
|
| I2 | quantity, `currentValue`, `averagePurchasePrice`, `absoluteReturn`, `cashBalances` и агрегаты не зависят от sector/issuer; `null` не делает стоимость неизвестной. Исключение — отказ token (PVΔ:104) | PVΔ:3; PC:484 |
|
||||||
|
| I3 | В одном разрезе позиция участвует не более одного раза; фонд попадает в «Секторы», но не в «Акции», «Облигации», «Эмитенты» | PC:69 |
|
||||||
|
| I4 | Учитываются только позиции с `currentValue` > 0 в выбранной валюте; отрицательные и неоценённые исключаются, не берутся по модулю | PC:129 |
|
||||||
|
| I5 | Знаменатель = сумма учитываемых позиций разреза в выбранной валюте = сумма элементов; Cash Balance и `totalsByCurrency` не участвуют; `null`-группа входит в сумму | PC:289, PC:349 |
|
||||||
|
| I6 | «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными, не подменяются кодом T-Bank и не сливаются с реальным значением | PC:349 |
|
||||||
|
| I7 | До 6 элементов — все строки; от 7 — пять и «Прочее» (сумма остальных, число остальных); метрика «доля топ-5» — только при ≥ 6 элементах | PC:229, PC:324 |
|
||||||
|
| I8 | Порядок: сумма по убыванию; при равенстве — название (цифры, латиница, кириллица), затем порядок первой позиции. Название эмитента — по позиции с наибольшим `currentValue`, при равных — меньшее по алфавиту; сумму и группировку оно не меняет | PC:274, PC:114 |
|
||||||
|
| I9 | Точные доли дают ровно 100 %; «Прочее» = сумма долей остальных; округление только при показе; сумма показанных долей может не быть 100 % | PC:289, PC:304 |
|
||||||
|
| I10 | Список, диаграмма и центр используют одни суммы; раскрытие «Прочее» не меняет диаграмму; в центре — сумма разреза и, при метрике, «Топ-5 · N %» | PC:254, PC:402, PC:415 |
|
||||||
|
| I11 | Валюты вкладки — ненулевые оценки STOCK/BOND/FUND (включая отрицательные), по ISO; выбора нет при ≤ 1 валюте | PC:169, PC:184 |
|
||||||
|
| I12 | N сноски = позиции разреза с `currentValue` `null` в любой валюте + отрицательные в выбранной; нулевые и отрицательные другой валюты не считаются; `null` sector/issuer не считается | PC:144 |
|
||||||
|
| I13 | Название — представление, не идентификатор: разные `issuer.id` и разные `sector` не объединяются при одинаковой подписи | PC:96, PC:364 |
|
||||||
|
| I14 | Нет учтённых элементов — пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | PC:451 |
|
||||||
|
| I15 | `sector` и `issuer` отличны от `null` только у STOCK/BOND/FUND; значения обрезаны; `issuer` — пара целиком | PVΔ:3 |
|
||||||
|
| I16 | Поле и отсчёт 24 часов после получения определяются только таблицей PVΔ:156: ни один исход, кроме строк «Получено значение» и «Ответ „не найдено“ или значения нет», не меняет значение и не сдвигает отсчёт | PVΔ:156, PVΔ:141 |
|
||||||
|
|
||||||
|
Доля может быть периодической дробью. Требование 100 % относится к точным суммам, а не к сумме показанных процентов; последнюю долю подгонять нельзя. Текущий frontend helper использует `Number` (`frontend/src/entities/portfolio/model/display.ts:7-24`): он задаёт стиль показа, но не доказывает достаточную точность. Способ точного расчёта — design.
|
||||||
|
|
||||||
|
## Риски двойного учёта и классификации
|
||||||
|
|
||||||
|
| Ситуация | Риск | Правило |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Обыкновенная акция, привилегированная акция и облигации с одним `brand.uid` | Разные компании либо слияние самих позиций | В «Эмитентах» суммируется один `id`; в «Акциях» и «Облигациях» позиции остаются отдельными |
|
||||||
|
| Одна компания под разными брендами | Занижение концентрации | Разные `id` не объединять; эмитент — бренд T-Bank, а не юридическое лицо. Покрытие — Q-D1 |
|
||||||
|
| Разные бренды с одинаковым `name` | Слияние по подписи | Два элемента (PC:96) |
|
||||||
|
| Один `brand.uid`, разные `name` у позиций | Неоднозначное имя | Имя позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавиту (PC:114) |
|
||||||
|
| Фонд с тем же `brand.uid`, что у акции | Повторный учёт или учёт фонда как компании | `issuer` фонда есть в API, но «Эмитенты» его отбрасывают; в сумму эмитента фонд не входит |
|
||||||
|
| Фонд с `sector` | Раскладка состава фонда | Используется только `Etf.sector` позиции; состав, `focus_type`, название и управляющая компания не используются |
|
||||||
|
| Один бренд, разные сектора у акции и облигации | Подмена сектора эмитента | Каждая позиция в своём `sector`; сектор из `issuer` не выводится |
|
||||||
|
| Государственная облигация | Классификация по типу или названию | «Государственный сектор» только при `sector` = `government` |
|
||||||
|
| Cash, `CURRENCY` и денежный фонд | Двойной учёт денег | Cash и `CURRENCY` вне концентрации; фонд — по общим правилам |
|
||||||
|
| Повторяющийся FIGI | Дедупликация настоящих строк | Позиции не удаляются; повтор строки upstream и повтор доставки lookup — разные явления |
|
||||||
|
| Одна позиция в четырёх разрезах | Общий итог разрезов | Разрезы независимы, между ними не складывать |
|
||||||
|
| Раскрытое «Прочее» | Двойной учёт остатка | Детализация той же суммы, не новые сегменты |
|
||||||
|
|
||||||
|
## Ошибки преобразования
|
||||||
|
|
||||||
|
| Ошибка | Распознавание | Данные |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Нет надёжного UID/FIGI | Отсутствие ключа | Значение не присваивается; поле остаётся прежним или `null` |
|
||||||
|
| Ответ другого инструмента или актива | Несовпадение UID / FIGI / `asset_uid` с запросом | Исход «ответ о другом инструменте или активе» по PVΔ:156 |
|
||||||
|
| Пустой / только пробелы `sector` | Результат обрезки | Признак «значения нет» |
|
||||||
|
| Новый неизвестный непустой `sector` | Вне таблицы PC:374 | Не ошибка: код сохраняется и показывается как есть |
|
||||||
|
| Нет `asset_uid`, актива или бренда | Отсутствие звена цепочки | Признак «значения нет» |
|
||||||
|
| Пустые `brand.uid` или `brand.name` после обрезки | Непригодная пара | Признак «значения нет» для `issuer` целиком |
|
||||||
|
| `Brand.sector` использован вместо `Share/Bond/Etf.sector` | Нарушение M1 | Ошибка маппинга, даже если значения совпадают |
|
||||||
|
| Приведение кода к lowercase, перевод кода при выдаче | Изменённый ключ | Ошибка контракта; перевод только в подписи frontend |
|
||||||
|
| Старый JSON без ключей | Ключей нет | `null` на HTTP-границе |
|
||||||
|
| Потеря точности суммы, доля после округления | Нарушение I5, I9 | Округлённые подписи не служат источником данных |
|
||||||
|
| Тип вне STOCK/BOND/FUND имеет `sector`/`issuer` | Нарушение I15 | Принудительно `null` |
|
||||||
|
|
||||||
|
Сетевые ошибки, повторы, сроки и восстановление — [failure-model.md](failure-model.md).
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
|
||||||
|
|
||||||
|
- Пробелы — края обрезаются, значение из одних пробелов отсутствует (PVΔ:3; 3081).
|
||||||
|
- `issuer` у фонда — заполняется для STOCK/BOND/FUND (PVΔ:3), разрез фонд отбрасывает (PC:69) (3081).
|
||||||
|
- Разные названия одного бренда — название позиции с наибольшим `currentValue`; при равных — по алфавиту (PC:114; 3081, 3082).
|
||||||
|
- «Не найдено» от T-Bank — как «значения нет» (PVΔ:156; 3082).
|
||||||
|
- Исключение из правила «справочные сведения онлайн» для сектора и бренда (PVΔ:104; 3082).
|
||||||
|
|
||||||
|
Остаётся неизвестным. Это факты данных, они проверяются при реализации и решения владельца не требуют.
|
||||||
|
|
||||||
|
| ID | Что неизвестно | Чем проверяется |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Q-D1 | Доля позиций STOCK / BOND / FUND, у которых есть пригодная пара `brand.uid` / `brand.name`; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | Одноразовая read-only проверка на реальном Portfolio владельца в начале реализации: доля позиций с непустой парой и таблица «эмитент → типы бумаг». Низкое покрытие scope не меняет (`proposal.md:17`), но определяет, как будет выглядеть разрез на реальных данных |
|
||||||
|
| Q-D2 | Какие `sector` реально отдаются для фондов и государственных облигаций | Та же проверка: список различных значений `sector` по типам со счётчиками; сверка с таблицей PC:374. Неизвестные коды обрабатываются по PC:364 и решения не требуют |
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Documentation Impact
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Change `account-top-positions` (Issue [WealthLedger/main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48)) добавляет на страницу одного счёта вкладку «Концентрация» с разрезами «Акции», «Облигации», «Секторы», «Эмитенты», а в позиции Portfolio — сектор и эмитента (бренд актива). Для docs это значит:
|
||||||
|
|
||||||
|
- В AS-IS появляются новые понятия и правила: вкладка, разрезы, «Прочее», сектор, эмитент (бренд актива во вкладке), правило 24 часов для сектора и бренда.
|
||||||
|
- Сектор и бренд — исключение из правила «справочные сведения только онлайн» ([ADR 019](../../../../../docs/adr/019-hourly-reads-and-online-instrument-reference.md)). Решение оформляется новым [ADR 022](../../../../../docs/adr/022-stored-instrument-classification.md); текст ADR 019 не переписывается, в его статус добавляется ссылка.
|
||||||
|
- Хранение решено в design (B1): отдельная глобальная таблица `TbankInstrumentClassification`, не связанная с подключением. Её состав и поведение при отказах отражаются в `containers.md` и `data-freshness.md`.
|
||||||
|
- Правки AS-IS — после реализации и её слияния в `develop`. До этого docs не описывает поведение, которого в системе нет.
|
||||||
|
|
||||||
|
Пути ниже — относительно корня workspace `WealthLedger/`; для docs-репозитория полный путь — `docs/<path>`.
|
||||||
|
|
||||||
|
## Documentation Changes
|
||||||
|
|
||||||
|
| Action | Target | Category | Итоговое AS-IS | Source | Verification |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Изменить | `docs/domains/portfolio/overview.md`, разделы «Снимок и связанные области» и «Как читать экран» | Domain | На странице одного счёта рядом с «Позиции» есть вкладка «Концентрация». В ней четыре разреза: «Акции», «Облигации», «Секторы», «Эмитенты». По умолчанию открыт «Акции» и первая по ISO валюта. В каждом разрезе топ-5 элементов; при семи и более элементах остальные — в «Прочее», раскрытие не меняет топ-5. Суммы и доли — в одной валюте, конвертации нет. Вкладки нет в режиме «Все счета» и при загрузке, ошибке или пустом Portfolio. Вкладка не меняет вкладку по умолчанию. Деньги не входят в знаменатель долей (в отличие от «Структуры») | `specs/.../portfolio-concentration/spec.md` (Вкладка, Состояния, Переключение, Начальное состояние, Одна валюта); `proposal.md` What Changes; `design.md` D8–D10 | Сверка с мерджнутым frontend и итоговыми Specs; проверка ссылок |
|
||||||
|
| Добавить | `docs/domains/portfolio/concentration.md` (новый файл, если правила не умещаются в `overview.md`; решение при написании) | Domain / reference | Состав разрезов по типам позиций; топ-5 и «Прочее»; группы «Сектор не указан», «Эмитент не указан»; значение `government` с подписью «Государственный сектор»; правила долей и одной валюты | `specs/.../portfolio-concentration/spec.md` (Состав разрезов, Названия элементов); `proposal.md` | Линк из `overview.md` и `docs/README.md`; markdownlint |
|
||||||
|
| Изменить | `docs/domains/portfolio/data-reference.md`, таблица «Состав и денежные показатели»; раздел «Структура и доли» | Reference | Позиция получает `sector` (сектор T-Bank без перекодирования, пробелы обрезаны, пустое → `null`; только `STOCK`, `BOND`, `FUND`) и `issuer` (бренд актива T-Bank; `null` вне разрезов; фонды в разрез «Эмитенты» не входят). `logoName` не меняется. В «Структуре» отметить: знаменатель и валютные итоги включают деньги, вкладка «Концентрация» — нет | `specs/.../portfolio-view/spec.md` «Нормализованный состав Portfolio» (PVΔ:3); `data-mapping.md` M1, M2; `design.md` D7 | Сверка с кодом ответа и с OpenAPI, выпущенным backend; DTO не копируется |
|
||||||
|
| Изменить | `docs/domains/portfolio/flows/portfolio-loading.md`, разделы «Основной обмен» и «Ошибки и результат для пользователя» | Flow | Сектор и бренд получаются в том же часовом обходе, в шаге до публикации Snapshot: после GetPortfolio и GetPositions. Первая попытка по позиции — не раньше чем через 24 часа после последнего успешного получения; пока значение не получено, попытка повторяется при каждом обходе. Ошибка получения не мешает публикации Snapshot и не меняет `isStale` и `lastSuccessfulAt`, кроме отказа token: он даёт `400 INVALID_TBANK_TOKEN` без fallback. Сохранённое значение остаётся доступным. Обычный GET по-прежнему не обращается к T-Bank. Фраза «Metadata инструмента дополняет представление независимо» уточняется: сектор и эмитент входят в Snapshot | PVΔ:104, PVΔ:141, PVΔ:156; `failure-model.md`; `design.md` B2, D1–D5 | Сверка с таблицей исходов PVΔ:156 и с кодом после слияния |
|
||||||
|
| Изменить | `docs/integrations/tbank.md`, таблица «Данные и ответственность»; раздел «Публичные логотипы» | Integration | Новые данные из T-Bank: сектор инструментов акций, облигаций и фондов (вызовы `ShareBy`, `BondBy`, `EtfBy`); бренд актива (uid и название) через `GetAssetBy` по `asset_uid`. Эмитент не выводится из названия, тикера или ISIN. Логотип по-прежнему приходит из metadata инструмента (`logoName`) и не равен эмитенту. Вызовы проходят через общий лимитер (по умолчанию 5/с) и ограниченную очередь | `data-mapping.md` «Источники-кандидаты в T-Bank»; `design.md` B4, D6 | Сверка с кодом T-Bank-клиента после слияния; ссылки на CDN не меняются |
|
||||||
|
| Изменить | `docs/architecture/cross-cutting/data-freshness.md`, раздел «Клиентский и серверный снимки» (список данных SQLite) и абзац про часовой обход | Architecture (cross-cutting) | SQLite дополнительно хранит классификацию инструментов (сектор и бренд) в глобальной таблице `TbankInstrumentClassification`: она не принадлежит пользователю или подключению и не ограждается по connection revision. Каждое поле имеет свой возраст (`sectorFetchedAt`, `brandFetchedAt`). Классификация не входит в `meta.dependencies` и не меняет свежесть Snapshot. Прежнее утверждение «общие сведения об инструментах запрашиваются онлайн» получает оговорку про это исключение и ссылку на ADR 022 | `design.md` Data / Storage Changes, D1, D4; `design.md` Cross-cutting «Freshness»; PVΔ:104, PVΔ:3 | Сверка с миграцией Prisma после слияния; проверка ссылки на ADR 022 |
|
||||||
|
| Изменить | `docs/architecture/containers.md`, раздел «Хранимые данные и границы доступа» (абзац про portfolio и общие сведения) | Architecture (data) | Добавить: SQLite содержит таблицу `TbankInstrumentClassification` (провайдер, ключ инструмента, сектор с возрастом, uid актива и бренда с именем и возрастом). Данные публичные, без токена и позиций. Снимок Portfolio полей классификации отдельно не хранит: поля `sector` и `issuer` позиций заполняются из таблицы при публикации | `design.md` Data / Storage Changes; B2; PVΔ «Ограничения доступа» | Сверка с `schema.prisma` и миграцией после слияния |
|
||||||
|
| Изменить | `docs/glossary.md`, блок Portfolio (после «Currency Instrument Position», до «Instrument»): новые термины | Glossary | См. раздел «Glossary / Navigation Impact» | `proposal.md`; `specs/.../portfolio-concentration/spec.md`; решение владельца (3083) | Формат: `## Термин`, определение и «_Не использовать_»; ссылки на домен |
|
||||||
|
| Изменить | `docs/overview.md`, таблица «Основные задачи пользователя», строка «Понять текущий состав инвестиций» | Navigation | Добавить задачу «увидеть, где сконцентрированы деньги: бумаги, секторы, эмитенты» со ссылкой на портфель | `proposal.md` (Why) | markdownlint, relative-links |
|
||||||
|
| Изменить | `docs/README.md`, таблица «Предметные области», строка «Портфель» | Navigation | Строка «Позиции, остатки, суммы, структура и свежесть» дополняется концентрацией (и ссылкой на новый файл, если он появится) | Как выше | markdownlint, relative-links |
|
||||||
|
| Новый ADR | `docs/adr/022-stored-instrument-classification.md` и строка в `docs/adr/README.md` | ADR | Фиксирует исключение: сектор и бренд актива хранятся как последнее известное значение с отдельным возрастом, а не запрашиваются онлайн. Область: сектор для `STOCK`/`BOND`/`FUND`, бренд актива; 24-часовой отсчёт; сохранение при ошибке; глобальная таблица без привязки к подключению | `design.md` B1, D1–D2; PVΔ:104; ADR 019; решение владельца (3083) | Номер и отсутствие дубликата проверить на момент записи; ссылки проходят |
|
||||||
|
| Изменить (связь) | `docs/adr/019-hourly-reads-and-online-instrument-reference.md`, строка **Status** | ADR | Добавить: «дополнен [ADR 022](022-stored-instrument-classification.md) в части сектора и бренда актива позиции». Текст решения 019 не переписывается | `docs/CONTRIBUTING.md` («Подтверждённо заменённое решение связывается с новым, а не переписывается») | Ссылка открывается; markdownlint |
|
||||||
|
| Изменить (условно) | `docs/domains/portfolio/all-accounts.md`, раздел «Маршруты и навигация» | Navigation | Одна строка: вкладки «Концентрация» в общем режиме нет. Если владелец решит, что это не нужно описывать — не менять | `specs/.../portfolio-concentration/spec.md` («Режим «Все счета»») | Решение при написании |
|
||||||
|
|
||||||
|
### Что не меняется (и почему)
|
||||||
|
|
||||||
|
| Target | Причина |
|
||||||
|
| --- | --- |
|
||||||
|
| `docs/architecture/containers.md`, Mermaid-диаграмма Container и раздел «Контейнерная поставка» | Новых внешних систем и контейнеров нет; T-Bank Brands CDN используется только для изображений, как и раньше |
|
||||||
|
| `docs/architecture/context.md`, Mermaid-диаграмма и текст | Тот же набор внешних систем |
|
||||||
|
| `docs/architecture/http-api.md` | Контракт генерирует backend (OpenAPI). Новые необязательные поля не копируются в docs |
|
||||||
|
| `docs/domains/accounts/overview.md` | Перечня вкладок страницы счёта в docs нет; вкладка относится к портфелю |
|
||||||
|
| `docs/domains/instruments/stocks.md`, `docs/domains/instruments/bonds.md` | Сектор и бренд показываются только во вкладке счёта. Файлы **не читались целиком** — проверить поиском «сектор» перед PR |
|
||||||
|
| `docs/domains/portfolio/all-accounts.md` (кроме условной строки выше) | Общий режим без вкладки; новых маршрутов нет |
|
||||||
|
| `docs/CHANGELOG.md` | Генерируется release-it из коммитов; вручную не править |
|
||||||
|
| `docs/architecture/cross-cutting/ui-resilience.md` | Не читалось полностью. Проверить, описывает ли оно состояния страницы счёта |
|
||||||
|
|
||||||
|
## Diagram Promotion
|
||||||
|
|
||||||
|
Перенос диаграмм не требуется. По умолчанию перенос не делается (`schema.yaml`, `docs-impact`; `docs/CONTRIBUTING.md`).
|
||||||
|
|
||||||
|
- `analysis/ux-design.md`, Mermaid flowchart «Страница счёта → Концентрация» — UI-поток этого change, не системная архитектура. В docs не переносится.
|
||||||
|
- `visuals/*.svg` — материалы Visual Package; в docs не размещаются.
|
||||||
|
- Существующие диаграммы (Container, Context, sequence подключения T-Bank) не меняются.
|
||||||
|
- Sequence «часовой обход → классификация → Snapshot» в docs **не добавляется**: поток описан текстом в `portfolio-loading.md`. Если понадобится, её делает автор docs-PR по `design.md` B2.
|
||||||
|
|
||||||
|
## ADR Decision
|
||||||
|
|
||||||
|
**Практика ADR в docs есть:** журнал `docs/adr/`, индекс в `docs/adr/README.md`, последний номер — 021, следующий — **022**. Формат — по ADR 019: `# NNN. Название`, `**Status:**`, `## Decision`, `## Consequences`. Принятый ADR не переписывается; замена связывается ссылкой на новый (`docs/CONTRIBUTING.md`).
|
||||||
|
|
||||||
|
**Решение владельца (3083):** добавить ADR для хранения сектора и бренда как исключения из online-only справочных данных.
|
||||||
|
|
||||||
|
**ADR 022 — `docs/adr/022-stored-instrument-classification.md`, статус `Accepted`.** Содержание, подтверждённое источниками:
|
||||||
|
|
||||||
|
- Context: ADR 019 (справочные сведения онлайн; старые строки не источник актуальности); PVΔ:104 (исключение для сектора и бренда); design B1 (общий metadata-кэш не подходит: `Instrument` не содержит сектора, `brand` — `BrandData`, а не `Brand{uid,name}`).
|
||||||
|
- Decision: глобальная таблица `TbankInstrumentClassification` с последним известным значением сектора и бренда, возраст полей раздельно; обновление по правилу PVΔ:141 (не раньше 24 ч после последнего успешного получения); четыре исхода PVΔ:156; без привязки к пользователю и подключению.
|
||||||
|
- Consequences: первый долговременный справочник T-Bank в системе; число вызовов T-Bank растёт при первом обходе (разовый всплеск, ограничен D6); отказ token обрабатывается как в обходе Portfolio; пересмотр, если появится второй потребитель (design ADR Candidates).
|
||||||
|
- Альтернативы: design Solution Assessment, варианты B и C. В ADR описываются кратко, со ссылкой на design.
|
||||||
|
|
||||||
|
ADR 022 пишется в том же docs-PR, что и изменения AS-IS, или раньше — и тогда в статус ADR 019 добавляется ссылка в том же PR, что и ADR 022.
|
||||||
|
|
||||||
|
## Glossary / Navigation Impact
|
||||||
|
|
||||||
|
### Глоссарий (`docs/glossary.md`)
|
||||||
|
|
||||||
|
Новые термины — в блоке Portfolio (после «Currency Instrument Position», до «Instrument»). Формат: `## Термин`, краткое определение, строка `_Не использовать_`.
|
||||||
|
|
||||||
|
| Термин | Что писать |
|
||||||
|
| --- | --- |
|
||||||
|
| **Концентрация** | Вкладка страницы одного счёта с разрезами по бумагам, секторам и эмитентам текущего Portfolio. Ссылка на домен портфеля. Не «структура» |
|
||||||
|
| **Разрез** | Одна из четырёх группировок вкладки «Концентрация»: набор элементов, на которые делится оценка позиций |
|
||||||
|
| **Сектор** | Значение сектора T-Bank для позиций `STOCK`, `BOND`, `FUND`, без собственной классификации WealthLedger. Государственные облигации — значение `government` с подписью «Государственный сектор» |
|
||||||
|
| **Эмитент** | В разрезе «Эмитенты» вкладки «Концентрация» — бренд актива по данным T-Bank. Фонды в разрез не входят. **Оговорка (решение владельца 3083):** в этом разрезе слово означает бренд актива T-Bank, а не юридического эмитента ценной бумаги. Строка «_Не использовать_»: юридический эмитент, эмитент рейтинга (см. [рейтинг выпуска НКР](integrations/nkr.md)) |
|
||||||
|
| **«Прочее»** | Группа остальных элементов разреза при семи и более элементах. Это UI-подпись: описывается в доменной странице, в глоссарии — одна строка-ссылка |
|
||||||
|
|
||||||
|
Владелец оставил термин «Эмитенты». Конфликт с юридическим эмитентом в NKR, ADR 013, `context.md` и `bonds.md` сохраняется; оговорка в глоссарии — единственное место, где он снимается. Остальные страницы, использующие «эмитент» в значении рейтинга или выпуска, не меняются.
|
||||||
|
|
||||||
|
Не добавлять отдельными записями: «Сектор не указан», «Эмитент не указан», «Государственный сектор» — UI-подписи, описываются в доменной странице.
|
||||||
|
|
||||||
|
Связанная существующая запись: «Instrument Logo» (строка про `logoName`) — после change явно указать, что логотип не равен эмитенту.
|
||||||
|
|
||||||
|
### Навигация
|
||||||
|
|
||||||
|
- `docs/domains/portfolio/overview.md` — главная страница для концентрации; ссылки на `data-reference.md` и новый файл (если будет).
|
||||||
|
- `docs/overview.md`, `docs/README.md` — строки из таблицы изменений.
|
||||||
|
- `docs/architecture/README.md` — новых вопросов не добавляется.
|
||||||
|
- `docs/adr/README.md` — строка 022 в индексе.
|
||||||
|
|
||||||
|
## Порядок относительно реализации
|
||||||
|
|
||||||
|
Порядок из `docs/CONTRIBUTING.md` («Фазы работы»), `docs/AGENTS.md` и правила AS-IS. Design задаёт выкат: backup, миграция `CREATE TABLE`, backend, публикация OpenAPI, frontend (`design.md`, Migration / Rollout).
|
||||||
|
|
||||||
|
1. **Сейчас (G2 approved, G3 — design package):** AS-IS страницы не меняются. Создать пункты `[docs]` в `tasks.md` по таблице выше.
|
||||||
|
2. **Во время реализации:** черновики текста допустимы в issue-linked ветке docs от `develop`. ADR 022 — в статусе `Accepted` (решение принято владельцем 3083).
|
||||||
|
3. **Слияние в `develop` docs-PR** — после слияния backend и frontend и проверки на окружении: AS-IS-тексты не должны обещать поведение, которого в `develop` нет. ADR 022 можно слить раньше, если его текст не описывает поведение, а фиксирует решение.
|
||||||
|
4. **Перед завершением:** сверка «текст ↔ код ↔ Specs»; `docs-sync-audit` или ручная проверка; проверки из следующего раздела.
|
||||||
|
|
||||||
|
Заголовок PR — conventional commit (`docs/commitlint.config.cjs`; CI `commits.yml` запускает `commitlint --strict`). В описании PR — ссылка на Issue #48 и change-id.
|
||||||
|
|
||||||
|
## AS-IS правила
|
||||||
|
|
||||||
|
- Docs описывает только поведение, которое есть в `develop` и проверено. Спецификация change не копируется в docs как требование.
|
||||||
|
- Формулировки «будет», «планируется» в AS-IS-страницах не используются.
|
||||||
|
- Текущие утверждения, которые станут неверными после change (исправлять только после реализации):
|
||||||
|
- `docs/architecture/cross-cutting/data-freshness.md` — список данных SQLite и онлайн-правило для инструментов;
|
||||||
|
- `docs/domains/portfolio/flows/portfolio-loading.md` — «Metadata инструмента дополняет представление независимо»;
|
||||||
|
- `docs/adr/019-…` — онлайн-правило: исключение описывает ADR 022; текст 019 не переписывать;
|
||||||
|
- `docs/domains/portfolio/data-reference.md` — «Структура» и «Концентрация» не должны выглядеть одним показателем.
|
||||||
|
- Один факт — один источник: правила сектора и бренда живут в `portfolio-view` Specs; docs ссылается на них, а таблицы исходов из `failure-model.md` не копирует.
|
||||||
|
- Имена RPC T-Bank в docs упоминаются только как категории источников; точный контракт — код и `design.md`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
Из корня docs (`docs/`), по `docs/CONTRIBUTING.md` и `docs/AGENTS.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvm use --silent # если nvm не загружен, сначала загрузить $HOME/.nvm/nvm.sh (см. корневой AGENTS)
|
||||||
|
npm ci # если зависимости не установлены
|
||||||
|
npm run verify # = npm run lint && git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm run lint`: markdownlint по `**/*.md` (кроме `node_modules` и `CHANGELOG.md`) с правилом относительных ссылок; отдельно `CHANGELOG.md`.
|
||||||
|
- Коммит: `npm run commitlint -- --strict`.
|
||||||
|
- Ручные проверки, которые lint не делает: якоря в ссылках; смысловая сверка с кодом и Specs; маршрут чтения в `README.md`; новый файл достижим из индекса.
|
||||||
|
- **Рендер Mermaid не проверяется** ни одним инструментом из `package.json`. Если меняется диаграмма, отмечать «не проверено рендером».
|
||||||
|
- Для ADR 022: номер, отсутствие дубликата в `docs/adr/README.md`, ссылка из ADR 019.
|
||||||
|
|
||||||
|
В этой задаче проверки не запускались: правок в docs нет.
|
||||||
|
|
||||||
|
## Открытые вопросы и расхождения
|
||||||
|
|
||||||
|
1. **Конфликт термина «эмитент»** сохраняется вне вкладки: NKR, ADR 013, `context.md`, `bonds.md` используют слово для юридического эмитента. Оговорка в глоссарии снимает конфликт только для вкладки; остальные страницы — решение владельца, если оно понадобится.
|
||||||
|
2. **Конфликт с ADR 019** снимается ADR 022 (решение 3083). Текст ADR 019 остаётся историческим.
|
||||||
|
3. **Не полностью прочитаны:** `docs/domains/instruments/stocks.md`, `bonds.md`, `docs/architecture/cross-cutting/ui-resilience.md`, `docs/integrations/README.md`. Проверить поиском перед PR.
|
||||||
|
4. **Основание решения 3083** в этом файле не цитируется дословно: текст комментария Issue не читался в этой задаче. Перед написанием ADR 022 сверить формулировку с комментарием.
|
||||||
|
5. **Вне scope:** `docs/code-quality-review.md` лежит в корне docs, хотя `docs/AGENTS.md` запрещает аудиты в docs. Не трогается.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Модель отказов
|
||||||
|
|
||||||
|
## Контекст и границы
|
||||||
|
|
||||||
|
Модель описывает получение сектора и бренда актива в часовом обходе, а не новый процесс синхронизации. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](system-analysis.md). Источники и преобразования — [data-mapping.md](data-mapping.md). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
|
||||||
|
|
||||||
|
Нормативная основа. Поведение при получении сектора и бренда определяют Specs; модель их не пересказывает и не дополняет, а относит отказы T-Bank к соответствующим исходам:
|
||||||
|
|
||||||
|
- PVΔ:141 «Обновление сектора и эмитента» — когда получение происходит.
|
||||||
|
- PVΔ:156 «Результат получения сектора и эмитента» — четыре исхода; для каждого таблица задаёт поле позиции и отсчёт 24 часов. Неуспешное получение не мешает обновлению остальных данных Portfolio.
|
||||||
|
- PVΔ:104 «Свежесть и fallback полного Portfolio Snapshot» — хранимое последнее известное значение и исключение «отказ token».
|
||||||
|
- PVΔ:3 «Нормализованный состав Portfolio» — сектор и эмитент не влияют на количество, стоимость, агрегаты, успешность и свежесть Snapshot, кроме отказа token.
|
||||||
|
|
||||||
|
Финансовый Snapshot по-прежнему требует успешных GetPortfolio и GetPositions (`docs/domains/portfolio/flows/portfolio-loading.md:10-21`; `backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-283`). Обычный GET и его повтор не обращаются к T-Bank (`specs/openspec/specs/tbank-data-sync/spec.md:9-14`). Новых очередей, блокировок, таблиц и библиотек модель не вводит; их выбор — design.
|
||||||
|
|
||||||
|
## Классификация отказов по исходам PVΔ:156
|
||||||
|
|
||||||
|
Для сопоставления отказов с нормой используется только эта таблица; значения полей и отсчёт в неё не копируются.
|
||||||
|
|
||||||
|
| Наблюдаемое событие | Исход по PVΔ:156 |
|
||||||
|
| --- | --- |
|
||||||
|
| Получено непустое значение | «Получено значение» |
|
||||||
|
| Ответ «не найдено» (в том числе NOT_FOUND на уровне RPC), нет поля / пусто / только пробелы после обрезки, нет `asset_uid`, актива или бренда, непригодная пара `brand.uid` / `brand.name` | «Ответ „не найдено“ или значения нет» |
|
||||||
|
| Ответ с UID / FIGI / `asset_uid` не запрошенного инструмента или актива | «Ответ о другом инструменте или активе» |
|
||||||
|
| Timeout, UNAVAILABLE, RESOURCE_EXHAUSTED, переполнение локальной очереди, ошибка декодирования, любой иной код, кроме отказа token | «Иная ошибка, кроме отказа token» |
|
||||||
|
| PERMISSION_DENIED, UNAUTHENTICATED (сведены клиентом к InvalidToken) | Отказ token: не из таблицы, обработка по PVΔ:104 |
|
||||||
|
|
||||||
|
Следствие: таблица PVΔ:156 содержит ровно четыре исхода плюс отказ token; других вариантов поведения модель не вводит.
|
||||||
|
|
||||||
|
## Сценарии отказов
|
||||||
|
|
||||||
|
«Известное значение» — значение, которое сохранено в позиции по PVΔ:104. Сектор и бренд получаются независимо.
|
||||||
|
|
||||||
|
| ID | Граница / шаг | Отказ | Обнаружение | Требуемое поведение системы | Наблюдаемый результат | Requirement |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| F01 | T-Bank → sync: финансовые ответы | GetPortfolio или GetPositions не получен | Исход RPC | Новый полный Snapshot не публикуется; действует прежний fallback Portfolio. Успех получения sector/brand его не заменяет | Прежний Snapshot с `isStale` либо отсутствие данных | `portfolio-view:96` (canonical); `hourly-investment-sync:8` |
|
||||||
|
| F02 | Получение сектора или бренда | Timeout, UNAVAILABLE, RESOURCE_EXHAUSTED, ошибка декодирования — иная ошибка, кроме отказа token | Код RPC после попыток клиента | По строке «Иная ошибка, кроме отказа token» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156 |
|
||||||
|
| F03 | Получение бренда (инструмент → `GetAssetBy`) | То же для любого из двух запросов цепочки | Исход RPC | Как F02 — по той же строке PVΔ:156; сектор и бренд независимы | Как F02 | PVΔ:156 |
|
||||||
|
| F04 | Успешный ответ без значения, «не найдено» | Нет `sector`, `asset_uid`, `brand`, пусто или только пробелы; ответ «не найдено» | Валидация маппинга (признаки — data-mapping); код NOT_FOUND | По строке «Ответ „не найдено“ или значения нет» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156; PVΔ:3 (обрезка пробелов) |
|
||||||
|
| F05 | Идентификация | Ответ о другом инструменте или активе | Сравнение UID / FIGI / `asset_uid` с запросом | По строке «Ответ о другом инструменте или активе» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156; PVΔ:3 (запрет определять эмитента по названию) |
|
||||||
|
| F06 | Обогащение набора позиций | Часть позиций получена, часть нет | Результат по каждому полю | Каждое поле — по своему исходу PVΔ:156; Snapshot публикуется целиком | Известные и неизвестные группы наравне; суммы не меняются | PVΔ:156; PC:349 |
|
||||||
|
| F07 | Лимитер / квота T-Bank | RESOURCE_EXHAUSTED либо переполнение локальной очереди | Код RPC; локальный отказ admission | Ограниченные повторы клиента (см. «Таймауты и повторы»); после них — F02 | Как F02 | PVΔ:156 |
|
||||||
|
| F08 | Отказ token при получении | PERMISSION_DENIED или UNAUTHENTICATED из запроса сектора или бренда | Код gRPC → InvalidToken | Обрабатывается так же, как отказ token при обходе Portfolio | Как задано PVΔ:104 (сценарий «Отказ token при получении бренда»); повторов в этом обходе нет | PVΔ:104 |
|
||||||
|
| F09 | Публикация финансового Snapshot | Транзакция не завершена | Отказ публикации | Нового успеха нет, остаётся прежний Snapshot; обогащение не маскирует отказ | Нет ложного `lastSuccessfulAt` | `portfolio-view:96` (canonical) |
|
||||||
|
| F10 | Сохранение полученных sector/brand | Данные получены, но не сохранены | Исход записи; несовпадение revision | Финансовый успех не отменяется. Значение не считается полученным: исход, который его сохранил бы, не наступил, поэтому поле и отсчёт 24 часов остаются прежними, а следующий обход повторяет получение, пока значение не получено (PVΔ:141) | Прежнее значение либо `null` | PVΔ:141; PVΔ:104 |
|
||||||
|
| F11 | Границы подключения | Token заменён, подключение удалено, счёт недоступен | Revision, membership, attempt | Поздний результат прежнего подключения не публикуется в новое; хранимые sector/brand относятся к подключению и не переходят в новое | Прежние `404`/`409`/`400`; нет утечки данных | `portfolio-view:99` (canonical); PVΔ:104; `docs/domains/portfolio/flows/portfolio-loading.md:24-29` |
|
||||||
|
| F12 | GET → frontend | Временная ошибка HTTP-чтения | Состояние Portfolio query | Сохранённые данные и разрезы остаются; HTTP-повтор читает только локальные данные. Вкладка не показывается при загрузке, ошибке и недоступности | Как на «Позициях» | PC:20; PC:484 |
|
||||||
|
| F13 | Старый JSON / Client Cached Snapshot | Ключей `sector`/`issuer` нет | Нет ключей | Трактуются как неизвестные; в ответе `null` | Группы «не указан» до ближайшего получения | PVΔ:3; PC:349 |
|
||||||
|
| F14 | Свежесть и полнота Portfolio | Snapshot устарел или `meta` обозначает неполноту, а sector/brand получены | `isStale`, `meta` | Время sector/brand не освежает финансовый Snapshot; при неполноте — единственное сообщение «Данные неполные» на вкладке, страничный Alert на этой вкладке не показывается | Разрезы по доступным данным; одно сообщение о неполноте | PC:464; PC:484 |
|
||||||
|
| F15 | Данные разреза | Нет учтённых позиций | Клиентская фильтрация | Пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | Нет NaN и фиктивных 100 % | PC:451; PC:129 |
|
||||||
|
| F16 | Долгая недоступность | Получение не удаётся дольше 24 часов | Время последнего успешного получения | По строке «Иная ошибка, кроме отказа token» PVΔ:156 каждый обход; предельного возраста у хранимого значения нет | Значение может отставать дольше 24 ч + интервал обхода, пока T-Bank не ответит | PVΔ:156; PVΔ:141; PVΔ:104 |
|
||||||
|
|
||||||
|
Группа «не указан» не различает причину: отсутствие у T-Bank, технический сбой, старый Snapshot. Новой пользовательской ошибки или статуса качества не вводится. Диагностика — существующие логи синхронизации (`specs/openspec/changes/account-top-positions/process-record.md:15-17`).
|
||||||
|
|
||||||
|
## Таймауты и повторы
|
||||||
|
|
||||||
|
### Подтверждённые ограничения AS-IS
|
||||||
|
|
||||||
|
| Механизм | Факт | Источник |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Deadline одного RPC | 15 000 мс | `backend/src/integrations/tbank/tbank.client.ts:55,589-613` |
|
||||||
|
| Попытки одного вызова | Максимум 2; пауза 1 000 мс | `backend/src/integrations/tbank/tbank.client.ts:51-53,518-562` |
|
||||||
|
| Повторяемые коды | DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, UNAVAILABLE | `backend/src/integrations/tbank/tbank.client.ts:548-552` |
|
||||||
|
| Прочее | PERMISSION_DENIED / UNAUTHENTICATED → InvalidToken; NOT_FOUND → `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальное → Unavailable | `backend/src/integrations/tbank/tbank.client.ts:571-583` |
|
||||||
|
| Очередь и скорость | Общий лимитер клиента, очередь 50, по умолчанию 5 стартов/с (локальная настройка, не квота T-Bank) | `backend/src/integrations/tbank/tbank.client.ts:50-52,147-187,259-261`; `backend/src/config/env.validation.ts:5,29-31` |
|
||||||
|
| Ожидание шага обхода | 900 000 мс; истечение не отменяет операцию | `backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:28-33` |
|
||||||
|
| Часовой обход | Не ранее 60 минут от старта прошлой попытки; без очереди пропущенных и без перекрытий | `specs/openspec/specs/hourly-investment-sync/spec.md:8-21` |
|
||||||
|
|
||||||
|
Две сетевые попытки по 15 с с паузой дают до 31 с без ожидания лимитера. Это не общий timeout цепочки «инструмент → актив». 15 минут планировщика не являются обещанием срока получения sector/brand.
|
||||||
|
|
||||||
|
### Требования, вытекающие из Specs
|
||||||
|
|
||||||
|
- Момент получения и повторы между обходами определяет PVΔ:141: получение при первом обходе, начавшемся не ранее чем через 24 часа после последнего успешного получения; пока значение не получено — при каждом обходе. Внутри обхода повторы — только прежние до двух попыток клиента. Новые циклы по открытию экрана, HTTP-повтору или раскрытию «Прочее» запрещены (`tbank-data-sync:9-14`).
|
||||||
|
- Обходы, начавшиеся раньше срока, значение не меняют (PVΔ:141, сценарий «Обход раньше 24 часов»); значения, у которых срок истёк одновременно, получаются в одном обходе.
|
||||||
|
- **Требование к реализации, не выбор.** NOT_FOUND от `EtfBy`, `BondBy` и `GetAssetBy` относится к исходу «значения нет» (PVΔ:156, решение 3082 «Как „значения нет“»). Текущий клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальное — в Unavailable (`tbank.client.ts:577-583`); без изменения такой ответ был бы ошибочно отнесён к «иной ошибке». Реальный вид ответа «не найдено» у каждого метода проверяется как Q-F3.
|
||||||
|
- Числовой общий бюджет времени получения и новый SLA Specs не задают; публикация финансов не должна ждать получения (PVΔ:3). Распределение вызовов внутри обхода — design.
|
||||||
|
|
||||||
|
### Число дополнительных вызовов
|
||||||
|
|
||||||
|
Обозначения: **P** — число позиций STOCK/BOND/FUND; **U** — число различных инструментов среди них; **A** — число различных `asset_uid`. У остальных типов вызовов нет.
|
||||||
|
|
||||||
|
- Без повторного использования результатов точечная схема даёт до `2P` логических вызовов: типизированный инструмент (sector и `asset_uid`) и `GetAssetBy` (бренд).
|
||||||
|
- С повторным использованием: до `U + A` в обход, когда срок истёк; при двух попытках клиента — до `2 × (U + A)` сетевых попыток. Если нужен ещё `GetInstrumentBy` для идентификации либо FIGI-fallback, число растёт. Оценка не выбирает алгоритм и не вводит квоту.
|
||||||
|
- При тёплых значениях — близко к нулю между суточными сроками; при систематических ошибках — до `U + A` каждый обход.
|
||||||
|
- `GetAssets` заменить `GetAssetBy` не может: в `Asset` нет `brand` (`backend/src/integrations/tbank/proto/contracts/instruments.proto:1075-1080`).
|
||||||
|
- 5 стартов/с делится с остальными вызовами клиента; соответствие внешним квотам не доказано (Q-F3).
|
||||||
|
|
||||||
|
## Идемпотентность и обработка дубликатов
|
||||||
|
|
||||||
|
Все внешние вызовы — чтения: повтор не создаёт активов и позиций и не меняет стоимость. Повтор получения заменяет значение поля по PVΔ:156, но не добавляет вторую позицию.
|
||||||
|
|
||||||
|
Публикация финансов защищена в AS-IS: scope — пользователь + revision подключения + счёт; работа ограждена `id`, `attemptId` и состоянием `running`; поздняя публикация завершённой попытки не проходит (`backend/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts:13-18,116-150`). HTTP-ключ идемпотентности для GET не нужен.
|
||||||
|
|
||||||
|
Для получения sector/brand ключ соответствия — провайдер T-Bank + instrument UID (затем FIGI); для бренда — `asset_uid`; для группировки на клиенте — `issuer.id`. Текущий `metadataKey` и объединение запросов по ключу (`backend/src/integrations/tbank/instrument-metadata.repository.ts:33-38`; `backend/src/integrations/tbank/instrument-metadata.service.ts:69-98`) подтверждают AS-IS, но не задают новый кэш. Обязательство design: обеспечить отсчёт срока по `PVΔ` «Обновление сектора и эмитента»; гранулярность и способ хранения времени последнего успешного получения выбирает design. Дедупликация одновременных запросов допустима, но не должна запрещать повтор, который требует PVΔ:141.
|
||||||
|
|
||||||
|
## Порядок и конкуренция
|
||||||
|
|
||||||
|
- Значение присваивается только по исходам PVΔ:156; ответ актива не применяется к произвольной позиции по названию. Сектор и бренд независимы и могут завершиться в любом порядке.
|
||||||
|
- Полнота и время финансового Snapshot определяются финансовыми ответами; одновременный GET может увидеть ещё не обновлённое значение, и это допустимо.
|
||||||
|
- Поздний результат прежнего подключения или пользователя не принимается; существующие ограждения публикации сохраняются (`portfolio.repository.ts:121-147`).
|
||||||
|
- `issuer` — пара целиком; `id` из одного ответа не сочетается с `name` из другого. Если разные позиции одного бренда несут разные `name`, выбор имени — клиентский (PC:114).
|
||||||
|
- Один инструмент на нескольких счетах использует одно определение; позиции счетов не объединяются.
|
||||||
|
- Часовые обходы не перекрываются; истёкшее ожидание не отменяет вызов, поздний ответ проверяет применимость (`hourly-investment-sync:8`).
|
||||||
|
|
||||||
|
## Частичный отказ и компенсация
|
||||||
|
|
||||||
|
При полном финансовом успехе и неполном получении публикуется весь состав с доступными значениями. Известный `sector` при неизвестном `issuer` допустим и наоборот; неудача одной позиции не затрагивает остальные. Отсутствие значения не удаляет позицию, не меняет `currentValue` и не переводит Portfolio в partial (PVΔ:3).
|
||||||
|
|
||||||
|
Компенсаций нет: чтение справочника внешних эффектов не создаёт. Откатывать успешные деньги из-за бренда нельзя. Если финансовая публикация не завершилась — это F09, а не отсутствие metadata.
|
||||||
|
|
||||||
|
Исключение — отказ token (F08): его последствия задаёт PVΔ:104; подключение становится недействительным так же, как при отказе token в обходе Portfolio (реакция AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:194-196`).
|
||||||
|
|
||||||
|
## Восстановление
|
||||||
|
|
||||||
|
1. Следующий обход, начавшийся не ранее срока PVΔ:141, либо любой следующий обход, пока значение не получено, повторяет получение; ручная синхронизация не нужна.
|
||||||
|
2. Результат каждого получения определяет PVΔ:156.
|
||||||
|
3. Хранимое значение живёт без предельного возраста (F16, PVΔ:104).
|
||||||
|
4. После замены подключения хранимые значения прежнего подключения не используются; восстановление идёт в новом подключении (F11).
|
||||||
|
5. GET между обходами читает хранимое и не вызывает T-Bank (PVΔ:104, сценарий «Сектор и бренд читаются из хранимого значения»).
|
||||||
|
6. **Расхождение AS-IS с нормами, которое закрывает реализация.** Общая metadata считается свежей по факту завершённого lookup без возраста, а запрос с `force=false` пропускает такие записи (`instrument-metadata.repository.ts:314-323`; `instrument-metadata.service.ts:75-79`). Для PVΔ:141 и PVΔ:156 это недопустимо: срок считается от времени последнего успешного получения каждого поля, а не от факта завершённого lookup.
|
||||||
|
|
||||||
|
## Совместимость во время отказа
|
||||||
|
|
||||||
|
- Старые потребители игнорируют новые поля; Money и состояния ресурса не меняются.
|
||||||
|
- Старый JSON не пересоздаётся; отсутствие ключей на HTTP-границе — `null`.
|
||||||
|
- Если metadata нет совсем, положительные позиции образуют группы «не указан», а не пустое состояние. Пустое состояние — только при отсутствии учтённых позиций (PC:451).
|
||||||
|
- Client cache не обходит `404`, отсутствие подключения и известный недействительный token (`specs/openspec/specs/portfolio-view/spec.md:150-157`).
|
||||||
|
- Ошибки sector/brand не попадают в `meta` Portfolio как причина неуспеха или устаревания. Отдельный статус качества metadata потребовал бы Specs.
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
|
||||||
|
|
||||||
|
- Срок и поведение после ошибки — PVΔ:141 и PVΔ:156.
|
||||||
|
- Отказ token — PVΔ:104.
|
||||||
|
- «Не найдено» от T-Bank — исход «значения нет» (PVΔ:156).
|
||||||
|
- Хранение вопреки «справочные сведения онлайн» — исключение для сектора и бренда (PVΔ:104).
|
||||||
|
- Два предупреждения о неполноте — только на вкладке (PC:464).
|
||||||
|
|
||||||
|
Остаётся неизвестным. Это факт о внешней системе и нагрузке; он проверяется при реализации и решения владельца не требует.
|
||||||
|
|
||||||
|
| ID | Что неизвестно | Чем проверяется |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Q-F3 | Фактические квоты `ShareBy`, `BondBy`, `EtfBy`, `GetAssetBy` для токена владельца; число U и A; доля обращений при холодном старте; в каком виде T-Bank отдаёт «не найдено» для `EtfBy`, `BondBy`, `GetAssetBy` (код NOT_FOUND либо пустой объект) | Официальные лимиты Invest API; измерение числа вызовов и времени в первом полном обходе по событиям `tbank.rpc.*` (`tbank.client.ts:536-569`); запрос с несуществующим `asset_uid` на тестовом окружении или мок по proto. Результат определяет параллелизм и признак ответа «не найдено» в клиенте; сама норма исхода от него не зависит |
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# QA Analysis
|
||||||
|
|
||||||
|
Ревизия спеков: G2 approved (comment 3082). Источник ожидаемого поведения — только
|
||||||
|
Delta Specs `portfolio-concentration` (28 ADDED, ниже PC) и `portfolio-view`
|
||||||
|
(2 MODIFIED + 2 ADDED, ниже PVΔ) этого change. Design пишется параллельно; на его
|
||||||
|
внутреннее устройство не опираемся. Case пишется только там, где нужны данные, шаги или oracle сверх Scenario;
|
||||||
|
остальные строки Coverage Matrix ссылаются на Scenarios. Написанные cases — в
|
||||||
|
`analysis/test-cases/{conc,meta,reg}.md`.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Риск | Затронутые требования | Вероятность | Ущерб | Глубина проверки |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Неверная классификация исхода получения (NOT_FOUND vs иная ошибка) ломает 24h-отсчёт и last-known | «Результат получения сектора и эмитента», F02–F05 | Высокая (клиент AS-IS отображает NOT_FOUND как Unavailable — failure-model:75) | Средний: группы «не указан» вместо значений | Интеграционные проверки всех 4 строк таблицы исходов + exploratory Q-F3 |
|
||||||
|
| Доли/суммы считаются от неверной базы (Cash Balance, нули, другие валюты, отрицательные) | PC:129, PC:144, PC:289 | Средняя | Высокий: недоверие владельца к цифрам | Компонентные проверки с числовыми оракулами из сценариев |
|
||||||
|
| Потеря/порча финансового Snapshot из-за обогащения (token, публикация, границы подключения) | «Свежесть и fallback полного Portfolio Snapshot», F08–F11 | Низкая | Критический: потеря данных портфеля | Интеграционные: token-отказ, гонка публикации, смена подключения; регрессия обхода |
|
||||||
|
| Ошибка округления/формата долей (`,0`, `< 0,1 %`, половина вверх) | PC:304, PC:324 | Средняя | Низкий | Компонентные с точными строками-эталонами |
|
||||||
|
| Ухудшение существующей вкладки «Позиций» / структуры портфеля | PC:9, регрессия | Средняя | Высокий | Регрессионный прогон (TC-REG) |
|
||||||
|
| Доступность диаграммы только цветом / недоступна скринридеру | PC:443 | Средняя | Средний | Ручная проверка + текстовый эквивалент в component |
|
||||||
|
| Квоты T-Bank при суточном обходе (до `U + A` вызовов) | F07, Q-F3 | Неизвестная | Средний: деградация синхронизации | Exploratory Q-F3 на тестовом токене; norm от квот не зависит |
|
||||||
|
|
||||||
|
## Coverage Matrix
|
||||||
|
|
||||||
|
Уровни: unit / integration (backend, configured HTTP tests) / component (frontend) / e2e / manual.
|
||||||
|
Ссылки на сценарии — `PC:<строка>` / `PVΔ:<строка>` файлов спек.
|
||||||
|
|
||||||
|
| Requirement | Сценарии / ветви | Проверка / ожидаемое наблюдение | Уровень | Test cases |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| PC:9 Вкладка «Концентрация» страницы счёта | 2 сценария (есть вкладка; «Все счета» — нет) + инвариант «прочие вкладки не меняются» | Вкладка рядом с «Позициями»; в «Всех счетах» отсутствует; дефолтная вкладка прежняя | e2e | Scenario «Вкладка на странице одного счёта», «Режим “Все счета”»; TC-REG-001 |
|
||||||
|
| PC:20 Вкладка в состояниях страницы | загрузка/ошибка/пусто/только Cash Balance | Вкладки нет при загрузке, ошибке и пустом Portfolio; есть при любом Cash Balance | component + e2e | TC-CONC-002 + Scenario «Загрузка и ошибка» |
|
||||||
|
| PC:37 Переключение разрезов | 4 разреза, один одновременно, порядок | Показан ровно один разрез, переключатели в заданном порядке | component | Scenario «Открытие вкладки», «Переключение разреза» |
|
||||||
|
| PC:49 Начальное состояние вкладки | новый заход/reload → «Акции»+первая валюта; вкладки страницы → сохранение | Reset при новом заходе и смене счёта, persist при переключении вкладок | e2e | TC-CONC-005, TC-CONC-006 |
|
||||||
|
| PC:69 Состав разрезов | 3 сценария (сектор объединяет; эмитент stock+bond без фонда; фонд вне акций/облигаций) | Агрегация и исключение типов по таблице | component | TC-CONC-007 |
|
||||||
|
| PC:96 Названия элементов | без name → ticker; `issuer.name`; разные `issuer.id` не сливаются | Название по цепочке name→ticker→FIGI; раздельные элементы при равных именах | component | TC-CONC-008 |
|
||||||
|
| PC:114 Название эмитента при разных названиях бренда | наибольшее `currentValue`; равные → меньший по алфавиту | Элемент назван по большей позиции; при равенстве — «Сбер» < «Сбербанк» | component | Scenario «Название по наибольшей позиции», «Равные наибольшие значения» |
|
||||||
|
| PC:129 Позиции, учитываемые в разрезе | положительные; 0 — не учитывается и не неучтённая; null/отрицательные исключены | Сумма разреза только из положительных позиций выбранной валюты | component | Scenario «Отрицательная и неоценённая позиции», «Позиция с нулевой стоимостью» |
|
||||||
|
| PC:144 Сноска о не учтённых позициях | N: null в любой валюте + отрицательные в выбранной; 0 → нет сноски | Точный N по правилу; сценарии 149–167 покрывают все ветви | component | Scenario «Сноска с числом позиций», «Неоценённая позиция при смене валюты», «Отрицательная позиция другой валюты», «Нет неучтённых позиций» |
|
||||||
|
| PC:169 Одна валюта за раз | смена валюты пересчитывает; сохраняется между разрезами; нет конвертации | Значения только в выбранной валюте, без смешанного итога | component | Scenario «Смена валюты», «Валюта сохраняется при смене разреза» |
|
||||||
|
| PC:184 Доступные валюты вкладки | STOCK/BOND/FUND ненулевые, включая отрицательные; 0 исключён; Select скрыт при ≤1; сортировка ISO | Список и сортировка точны; скрытие Select; пустое состояние без выбора | component | Scenario «Начальная валюта», «Единственная валюта», «Валюта только у позиции с нулевой стоимостью», «Валюта только у отрицательной позиции», «Нет оценённых позиций» |
|
||||||
|
| PC:214 Независимый выбор валюты вкладки | независимость от «Структуры портфеля»; пропавшая валюта → первая списка | Валюта вкладки не меняется от внешнего Select; fallback при исчезновении | component + e2e | TC-CONC-015 |
|
||||||
|
| PC:229 Топ-5 и строка «Прочее» | ≤6 строк без «Прочее» (5, 6, <5); ≥7 → топ-5 + «Прочее» с числом элементов | Границы 5/6/7 и формулировка числа элементов («2 элемента», «5 элементов») | component | TC-CONC-016 |
|
||||||
|
| PC:254 Раскрытие строки «Прочее» | раскрытие/сворачивание (мышь и клавиатура); не меняет топ-5 и диаграмму; свёрнуто при показе/смене валюты | Список остальных в порядке ранжирования; инвариант неизменности топ-5 | component + e2e | Scenario «Раскрытие “Прочее”», «Повторное нажатие», «Смена валюты сворачивает “Прочее”»; клавиатура — TC-CONC-027 |
|
||||||
|
| PC:274 Порядок элементов с равной суммой | цифры < латиница < кириллица, без регистра; граница 5-й строки | Порядок «VK», «X5», «Сбер»; «Альфа» в топ-5 при равенстве | component | Scenario «Равные суммы на границе пятой строки», «Цифры, латиница и кириллица при равных суммах» |
|
||||||
|
| PC:289 Расчёт долей | точные доли суммируются в 100 %; доля «Прочее» = сумма долей элементов; Cash Balance вне | Числовые оракулы сценариев 294–302 | component | Scenario «Доля без денежных остатков», «Доля “Прочее”»; числовой прогон — TC-CONC-007 |
|
||||||
|
| PC:304 Показ долей | округление до десятых, половина вверх, без «,0», «< 0,1 %», сумма ≠ 100 % допустима | Строки-эталоны «93,8 %», «6,3 %», «< 0,1 %», 100,1 % в сумме | component | TC-CONC-020 (все три ветви: середина вверх, «< 0,1 %», без «,0», сумма ≠ 100 %) |
|
||||||
|
| PC:324 Суммарная доля топ-5 | показ при ≥6, скрыта при ≤5 (7/6/5/1 элементы) | «Топ-5 · N %» только при ≥6; значения 95 %, 96 % | component | Ветви покрыты TC-CONC-016 (наборы 1–4) |
|
||||||
|
| PC:349 Группы «Сектор/Эмитент не указан» | группа ранжируется наравне, попадает в «Прочее», имя видно | Сценарии 354–362 | component | Scenario «Позиции без эмитента в топе», «Группа без сектора в “Прочее”» |
|
||||||
|
| PC:364 Подписи секторов | известные — русская подпись, неизвестные — как получено; нет своей классификации | Неизвестный код показан буквально | component | Scenario «Неизвестное значение сектора» |
|
||||||
|
| PC:374 Русские подписи известных секторов | 11 значений таблицы, включая `government` | Каждое значение → своя подпись (таблица — эталон) | component | TC-CONC-024 |
|
||||||
|
| PC:402 Диаграмма распределения разреза | 6 сегментов при ≤6; 5+«Прочее» при ≥7; одинаково при раскрытии; нет при пустом разрезе | Число сегментов и доли совпадают со списком | component + manual | Scenario «Семь элементов», «Ровно шесть элементов» (обе диаграммные нормы); визуальный блок — TC-CONC-027 |
|
||||||
|
| PC:415 Центр диаграммы | сумма точная; «Топ-5 · N %» при метрике; текстовый эквивалент | Центр: «95 %» + «1 000 ₽» / только сумма при ≤5 | component + manual | Scenario «Семь элементов», «Ровно шесть элементов» (обе диаграммные нормы); визуальный блок — TC-CONC-027 |
|
||||||
|
| PC:430 Строки без переходов | клик по строке — нет навигации; эмитенты без логотипов | URL не меняется; нет `<img>` логотипов | e2e | Scenario «Нажатие на строку акции», «Строка эмитента» |
|
||||||
|
| PC:443 Доступность диаграммы без цвета | текстовый эквивалент, сопоставимость сегментов без цвета | Скринридер читает название/сумму/долю каждого сегмента | manual + component | TC-CONC-027 |
|
||||||
|
| PC:451 Пустое состояние разреза | пустой разрез: заголовок + валюта, без списка/диаграммы; сноска и выбор сохраняются | Сценарий «Валюта только у отрицательной позиции» | component | Scenario «Валюта только у отрицательной позиции» (пустое состояние) |
|
||||||
|
| PC:464 Предупреждение о неполных данных | неполнота `meta` → одно «Данные неполные» на вкладке, без дублирования на странице; неоценённые позиции не вызывают | Одно сообщение; доли по доступным данным | component + e2e | Scenario «Неполный Portfolio», «Предупреждение не дублируется» |
|
||||||
|
| PC:484 Состояния Portfolio на вкладке | соответствие «Состояниям и ошибкам Portfolio на клиенте» | isStale/загрузка/ошибка отображаются как на «Позициях» | component | Scenario «Состояния Portfolio на вкладке “Концентрация”»; сверка с canonical — существующий regression-прогон frontend |
|
||||||
|
| «Нормализованный состав Portfolio» (MODIFIED) | sector/issuer nullable; обрезка пробелов; только пробелы = отсутствие; sector только у STOCK/BOND/FUND; issuer.id+name; запрет эмитента по названию | Поля по правилам; сценарии 30–60 delta; GET-контракт не сломан | integration (configured HTTP tests) | PVΔ Scenario «Сектор инструмента доступен/отсутствует», «Эмитент позиции доступен/отсутствует», «Эмитент не определяется по названию», «Недоступность сектора и эмитента не нарушает Snapshot»; обратная совместимость GET — существующий contract-прогон |
|
||||||
|
| «Свежесть и fallback полного Portfolio Snapshot» (MODIFIED) | исключение: token-отказ при sector/brand = 400 INVALID_TBANK_TOKEN; сектор/бренд — last-known, обновление по «Обновление сектора и эмитента»; GET не ждёт T-Bank | Fallback не маскирует token-отказ; хранимые sector/brand читаются локально | integration | TC-META-002; существующий backend contract-прогон |
|
||||||
|
| «Обновление сектора и эмитента» (ADDED) | первый обход ≥24 ч после успеха; интервал из hourly-investment-sync; «не найдено» тоже успех | Сценарии 149–152: значение обновляется в первый обход после 24 ч | integration | TC-META-003 |
|
||||||
|
| «Результат получения сектора и эмитента» (ADDED) | 4 строки таблицы: значение / нет значения / другой инструмент / иная ошибка; каждая — поле + отсчёт 24 ч | Каждая строка: точное поле и отсчёт (сценарии 170–202, включая перезапуск отсчёта от «не найдено») | integration | TC-META-004 |
|
||||||
|
|
||||||
|
## Negative and Boundary Focus
|
||||||
|
|
||||||
|
- Границы элементов: 1 / 5 / 6 / 7 (строки и метрика топ-5) — TC-CONC-016.
|
||||||
|
- Границы стоимости: 0 (исключена и не в N), отрицательная (в N только своей валюты),
|
||||||
|
`null` (в N в любой валюте), крайне малая доля «< 0,1 %» — Scenario «Отрицательная и неоценённая»/«Нулевая стоимость»/«Сноска о не учтённых позициях», TC-CONC-020.
|
||||||
|
- Округление: точная половина вверх (6,25 → «6,3»), сумма показанных ≠ 100 % — TC-CONC-020.
|
||||||
|
- Валютные границы: единственная валюта (Select скрыт), валюта только отрицательной
|
||||||
|
позиции (есть в выборе, разрез пуст), валюта пропала после обновления — Scenario «Доступные валюты вкладки», TC-CONC-015.
|
||||||
|
- Тайбрейки: равные суммы (цифры/латиница/кириллица), граница 5-й строки, равные
|
||||||
|
`currentValue` у одного `issuer.id`, одинаковые `issuer.name` при разных `id` — Scenario «Порядок элементов с равной суммой»/«Название эмитента при разных названиях», TC-CONC-008.
|
||||||
|
- 24h-отсчёт: успех → сброс; «не найдено» → сброс; иной инструмент / иная ошибка →
|
||||||
|
без сброса, повтор каждый обход; систематический сбой дольше 24 ч — TC-META-003/004.
|
||||||
|
- Запрещённые эффекты: GET не запускает T-Bank, sector/brand не освежают Snapshot,
|
||||||
|
enrichment не отменяет финансовый успех, «Прочее»-раскрытие не меняет топ-5 и диаграмму,
|
||||||
|
строка не ведёт на другую страницу — TC-META-002, Scenario «Раскрытие “Прочее”», «Нажатие на строку акции».
|
||||||
|
|
||||||
|
## Cross-system Verification
|
||||||
|
|
||||||
|
T-Bank → sync → хранилище → GET → frontend. Каждый F-сценарий failure-model:
|
||||||
|
|
||||||
|
| ID | Проверка | Test cases |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F01 | Финансовый отказ не замещается успехом sector/brand; прежний Snapshot | TC-META-002, TC-REG-004 |
|
||||||
|
| F02 | Иная ошибка: прежнее значение/`null`, отсчёт не меняется | TC-META-004 |
|
||||||
|
| F03 | Сбой любого звена цепочки инструмента → бренда — как F02; поля независимы | TC-META-004 |
|
||||||
|
| F04 | «Не найдено»/пусто/пробелы → `null` + сброс отсчёта | TC-META-004 + PVΔ Scenario «Ответ без значения заменяет известное значение» |
|
||||||
|
| F05 | Чужой UID/asset_uid → прежнее значение, повтор | TC-META-004 |
|
||||||
|
| F06 | Частичное обогащение: Snapshot публикуется целиком, группы наравне | PVΔ Scenario «Недоступность сектора и эмитента не нарушает Snapshot», Scenario «Позиции без эмитента в топе» |
|
||||||
|
| F07 | RESOURCE_EXHAUSTED → как F02 после повторов клиента; (квоты — Q-F3) | TC-META-004 |
|
||||||
|
| F08 | Token-отказ при sector/brand → 400 INVALID_TBANK_TOKEN, без fallback | TC-META-002, TC-REG-004 |
|
||||||
|
| F09 | Отказ публикации: нет ложного `lastSuccessfulAt` | TC-META-002 |
|
||||||
|
| F10 | Отказ сохранения sector/brand: поле и отсчёт прежние, финансовый успех цел | TC-META-003 |
|
||||||
|
| F11 | Смена/удаление подключения: 404/409/400, sector/brand не переходят в новое | TC-META-005, TC-REG-004 |
|
||||||
|
| F12 | Ошибка HTTP-чтения на клиенте: вкладка скрыта, данные не теряются | TC-CONC-002 |
|
||||||
|
| F13 | Старый JSON без `sector`/`issuer` → `null`, группы «не указан» | PVΔ Scenario «Недоступность сектора и эмитента не нарушает Snapshot», Scenario «Позиции без эмитента в топе» |
|
||||||
|
| F14 | Время sector/brand не освежает Snapshot; неполное `meta` — одно сообщение | TC-META-002; Scenario «Неполный Portfolio» |
|
||||||
|
| F15 | Пустой разрез: нет NaN и фиктивных 100 % | Scenario «Валюта только у отрицательной позиции» |
|
||||||
|
| F16 | Сбой дольше 24 ч: значение остаётся, повтор каждый обход | TC-META-003 |
|
||||||
|
|
||||||
|
Инварианты data-mapping: сумма разреза = сумма учитываемых позиций; доли в сумме
|
||||||
|
ровно 100 % (точные); отсутствие подстановки нуля вместо `null` — точные доли Scenario «Расчёт долей» и прогоны TC-CONC-007/016.
|
||||||
|
|
||||||
|
## Test Data and Environment Needs
|
||||||
|
|
||||||
|
- **Портфель (мультивалютный, ≥10 позиций):** акции ≥7 (для «Прочее»), ровно 6 и 5
|
||||||
|
вариантов; облигации; ≥2 фонда; позиция `CURRENCY`; `FUTURE`/`OTHER` для исключения.
|
||||||
|
Валюты: RUB + USD (два Select-варианта), одиночный RUB-вариант отдельным набором.
|
||||||
|
- **Стоимости:** положительные, 0, отрицательная, `null` (в т.ч. в невыполненной валюте);
|
||||||
|
1 RUB против 100 000 000 RUB («< 0,1 %»); пары с равными суммами (граница топ-5).
|
||||||
|
- **Бренды:** один `issuer.id` с разными `issuer.name` и разными суммами; равные суммы;
|
||||||
|
два `issuer.id` с одинаковым `issuer.name`; акции + облигации одного бренда; позиции
|
||||||
|
без бренда (группа «Эмитент не указан»).
|
||||||
|
- **Секторы:** каждый из 11 кодов таблицы PC:374 (минимум `it`, `government`, `energy`),
|
||||||
|
неизвестный код (`other`), пустой/пробельный, отсутствующий; один сектор у разных типов
|
||||||
|
(STOCK+BOND+FUND); ≥6 секторов для «Прочее» в «Секторах».
|
||||||
|
- **Upstream-моки (integration):** ответы T-Bank для всех 4 исходов таблицы «Результат получения сектора и эмитента»
|
||||||
|
(значение / «не найдено» NOT_FOUND и пустой объект / чужой UID / UNAVAILABLE-timeout /
|
||||||
|
PERMISSION_DENIED-token), частичный ответ, отказ публикации; `meta` неполноты.
|
||||||
|
- Окружение: локальное backend + frontend с тестовым T-Bank-подключением; для Q-D1/Q-D2/Q-F3 —
|
||||||
|
read-only доступ к реальному токену владельца.
|
||||||
|
|
||||||
|
## Manual / Exploratory Scope
|
||||||
|
|
||||||
|
- Соответствие макетам ux-design v2 (`visuals/`): кольцевая диаграмма, центр, бары строк,
|
||||||
|
«Прочее», предупреждение — по всем четырём разрезам.
|
||||||
|
- Контраст тёмной/светлой темы: сегменты, подписи, «< 0,1 %», сноска.
|
||||||
|
- Клавиатура: Tab по переключателям разрезов, строкам и «Прочее»; Enter/Space раскрытие
|
||||||
|
(PC:254), видимый focus, отсутствие «клик-ловушек».
|
||||||
|
- Скринридер (VoiceOver/NVDA): текстовый эквивалент диаграммы — название, сумма, доля
|
||||||
|
каждого сегмента в порядке списка; центр; сопоставление сегмент↔строка без цвета (PC:443).
|
||||||
|
- Русская локаль чисел: неразрывный пробел, `₽`/`$`, запятая в долях.
|
||||||
|
- Exploratory: поведение при переключении счёта, глубоких ссылок на вкладку (если есть),
|
||||||
|
медленной сети при раскрытии «Прочее».
|
||||||
|
|
||||||
|
## Automation Candidates
|
||||||
|
|
||||||
|
- Компонентный вычислитель разреза (состав, ранжирование, тайбрейки, доли, границы 5/6/7,
|
||||||
|
формат долей) — чистая функция от Portfolio: детерминированные оракулы из сценариев,
|
||||||
|
максимум ценности на единицу поддержки.
|
||||||
|
- Интеграционные проверки таблицы исходов «Результат получения сектора и эмитента» и 24h-логики (управляемое время) —
|
||||||
|
регрессионно-критичны, зависят от классификации ошибок клиента.
|
||||||
|
- e2e smoke: вкладка есть/нет, reset/persist состояния, отсутствие навигации по строкам,
|
||||||
|
одно «Данные неполные».
|
||||||
|
|
||||||
|
## Regression Impact
|
||||||
|
|
||||||
|
| Область | Риск | Проверка |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Вкладка «Позиции» существующего счёта | Вкладки/состояния страницы изменены | TC-REG-001: прежний дефолт, состав и порядок вкладок |
|
||||||
|
| PortfolioStructure («Структура портфеля») | Общий выбор валюты «протекает» на вкладку | TC-CONC-015; существующий regression-прогон PortfolioStructure |
|
||||||
|
| GET-контракт Portfolio | Новые поля sector/issuer ломают клиентов; старый JSON | Существующий backend contract-прогон (OpenAPI); обратная совместимость ответа |
|
||||||
|
| Часовой обход hourly-investment-sync | Новые вызовы сектора/бренда меняют нагрузку и успех обхода | TC-REG-004: успех/причины обхода без sector/brand-сбоев; F07/F11 |
|
||||||
|
| Инвалидация token | Новое ветвление token-отказа | TC-META-002/005: 400 INVALID_TBANK_TOKEN во всех точках, замена/удаление подключения — 404/409 |
|
||||||
|
|
||||||
|
## Entry / Exit Criteria
|
||||||
|
|
||||||
|
Вход: G2 approved (есть); design готов или проверка не зависит от него (component-
|
||||||
|
вычислитель и integration по спекам можно начинать); тестовые данные раздела выше доступны.
|
||||||
|
Выход: все строки Coverage Matrix зелёные либо явные `not verified` ниже; все F01–F16
|
||||||
|
покрыты; регрессия TC-REG-001/004 и существующие contract/regression-прогоны пройдены; ручной блок (макеты, контраст, клавиатура,
|
||||||
|
скринридер) зафиксирован; findings по Q-D1/Q-D2/Q-F3 записаны.
|
||||||
|
|
||||||
|
## Gaps / Blockers
|
||||||
|
|
||||||
|
- not verified: соответствие квотам T-Bank при суточном обходе — риск деградации
|
||||||
|
синхронизации; закрывается Q-F3 (измерение на реальном токене), норма исходов от
|
||||||
|
результата не зависит.
|
||||||
|
- not verified (до реализации): фактический вид «не найдено» у `EtfBy`/`BondBy`/`GetAssetBy`
|
||||||
|
(Q-F3) — до подтверждения классификация NOT_FOUND в проверках задаётся моком по proto;
|
||||||
|
расхождение с реальностью выявит Q-F3-проверка.
|
||||||
|
- Открытый data-fact: Q-D1 (покрытие brand-пары) и Q-D2 (реальные `sector` фондов и
|
||||||
|
госбумаг) — одноразовые read-only проверки на реальном Portfolio владельца; влияют на
|
||||||
|
ожидаемый вид разрезов, не на нормы. Результат фиксируется в QA-отчёте change.
|
||||||
|
- Пробелов в Specs, блокирующих проверку, нет (после G2 approved).
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Product Research
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
[F] Change Owner просит рядом с вкладкой «Позиции» показать «Топ-5»: крупнейшие акции, облигации и секторы по денежной оценке, переключение критерия и distribution chart; отдельно спрашивает, какой ещё критерий полезен, и формулирует эстетическую цель «сделать что-то классное и красивое» [R0].
|
||||||
|
|
||||||
|
[F] Сейчас страница счёта уже содержит donut «Структура портфеля» по классам активов с выбором одной валюты, вкладки и внутри «Позиций» переключатель «Акции / Облигации / Прочее» [L1, L2, L3].
|
||||||
|
|
||||||
|
[I] Исследовательский вопрос: какой узкий набор критериев даст владельцу понятный ответ «где сконцентрированы деньги», не дублируя текущую структуру по классам активов и не смешивая несопоставимые валюты.
|
||||||
|
|
||||||
|
[I] Документ является входным контекстом для продуктового решения, а не набором требований, контрактов или проектных решений.
|
||||||
|
|
||||||
|
## Target Audience
|
||||||
|
|
||||||
|
[F] Известная аудитория изменения — один владелец WealthLedger, использующий личный инвестиционный трекер поверх T-Bank Invest API [R0].
|
||||||
|
|
||||||
|
[H] Предположение о более широкой аудитории, частоте использования аналитики, размере портфеля, числе валют и уровне финансовой грамотности отсутствует: интервью, аналитика и обращения пользователей не предоставлены.
|
||||||
|
|
||||||
|
[I] Поэтому внешние продукты используются как источник кандидатов и паттернов, но не как доказательство спроса владельца WealthLedger.
|
||||||
|
|
||||||
|
## Research Questions
|
||||||
|
|
||||||
|
[I] 1. Какие разрезы концентрации и аллокации действительно встречаются в публично описанных портфельных продуктах, а какие остаются непроверенными кандидатами?
|
||||||
|
|
||||||
|
[I] 2. Какие критерии можно вычислить из текущего Portfolio Snapshot, а для каких в нём нет данных даже при наличии полей в интеграции T-Bank?
|
||||||
|
|
||||||
|
[I] 3. Какие формы визуализации подходят для рейтинга и для долей целого, как представлять «Top-N + Прочее» и насколько полезны доля топ-5, HHI и эффективное число позиций частному инвестору?
|
||||||
|
|
||||||
|
[I] 4. Как сохранить канонический принцип «без валютной конвертации» при сравнении позиций и категорий?
|
||||||
|
|
||||||
|
[I] 5. Где разместить новый обзор относительно существующих «Позиций» и «Структуры портфеля», чтобы назначение экранов не дублировалось?
|
||||||
|
|
||||||
|
## Existing Solutions and Alternatives
|
||||||
|
|
||||||
|
| Вариант | Что делает | Сильные стороны | Слабые стороны | Источник |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Оставить как есть | [F] Полный список позиций по типам и отдельный donut по классам активов уже доступны [L1, L2, L3]. | [I] Базовая альтернатива без нового дублирования и без новых данных. | [I] Не даёт явного рейтинга топ-5 и секторного разреза из запроса владельца. | [F] [L1, L2, L3] |
|
||||||
|
| Т-Инвестиции: «Аналитика портфеля» | [F] Публичная справка описывает структуру по классам активов, секторам, валютам и компаниям; вкладка «Портфель» показывает доли компаний с акциями и облигациями одного эмитента вместе [E1]. | [I] Подтверждает ценность не только сектора, но и валюты и сквозной концентрации на эмитента. | [F] На проверенной странице не описаны топ-5, сроки погашения, кредитные рейтинги или HHI [E1]. | [F] [E1] |
|
||||||
|
| Т-Инвестиции: доходность и выплаты | [F] Справка описывает прирост стоимости, дивиденды и купоны, а также график будущих выплат и стоимость портфеля на графике [E2]. | [I] Показывает соседний, но отличный от текущей концентрации сценарий — вклад в результат и доход. | [I] Эти данные не доказывают необходимость смешивания дохода и стоимости в одном переключателе «Топ-5». | [F] [E2] |
|
||||||
|
| Portfolio Performance | [F] Statement of Assets содержит классификации, долю, стоимость, donut и treemap; документация называет asset class, sector, region, industry среди возможных классификаций, а Performance содержит показатели по бумагам и выплаты по инструментам [E6, E7, E8, E9]. | [I] Самый широкий проверенный набор разрезов и форм представления среди изученных источников. | [F] Taxonomy может требовать ручной классификации; публичная документация не доказывает, что все разрезы нужны данному владельцу [E7]. | [F] [E6–E9] |
|
||||||
|
| Intelinvest | [F] Публичная продуктовая страница заявляет структуру по странам, секторам, регионам, компаниям и классам активов, а также аналитический доход по годам и месяцам [E3]. | [I] Подтверждает распространённость географии, отраслей и дохода в российском портфельном продукте. | [I] Это маркетинговое описание; детали расчёта, топ-5, облигационные корзины и accessibility на странице не проверены. | [F] [E3] |
|
||||||
|
| Snowball Income | [F] Публичная страница описывает ребалансировку по секторам и типам активов с расхождением текущего и целевого распределения [E5]. | [I] Подтверждает сценарий сравнения структуры с целью, а не только наблюдения за текущими долями. | [I] Наличие топ-5, эмитентской концентрации, maturity/rating и HHI на проверенной странице не подтверждено. | [F] [E5] |
|
||||||
|
| Sharesight, Morningstar X-ray, Kubera, Yahoo Finance, Finam | [I] Эти продукты рассматривались как кандидаты для выборки, но их конкретные критерии в доступных публичных страницах в ходе исследования не подтверждены. | [I] Не используются как доказательство «стандарта рынка». | [I] Не проверено; любые утверждения об их текущих разрезах потребуют отдельной проверки первичных источников. | [I] Нет подтверждённого источника по критериям |
|
||||||
|
| Простейший новый вариант: ranked bar/list | [I] Пять строк с названием, суммой и долей, при необходимости шестая строка «Прочее». | [I] Наглядно сохраняет ранг, точные подписи и доступное текстовое представление; не зависит только от различения цветов. | [I] Менее «эффектный» вид и ограниченная компактность при длинных названиях. | [I] Интерпретация на основе [L4, E10] |
|
||||||
|
| Donut «Top-5 + Прочее» | [F] Существующий виджет уже использует DonutChart, проценты/суммы в tooltip и текстовую легенду [L3]. | [I] Знакомый паттерн для долей целого и меньший риск визуального разнобоя. | [I] При повторении для классов активов будет дублировать виджет над вкладками; малые сектора труднее сравнивать по углам, чем по длинам. | [I] [L3] |
|
||||||
|
| Treemap | [F] Portfolio Performance предоставляет treemap для иерархических классификаций активов [E6]. | [I] Компактно показывает много категорий и может выглядеть выразительно. | [I] Для пяти категорий избыточен, а малые прямоугольники затрудняют подписи и точное сравнение. | [I] [E6] |
|
||||||
|
| Горизонтальная stacked bar | [I] Одна полоса показывает весь выбранный портфель и сегменты «Top-5 + Прочее». | [I] Компактная замена donut с явной общей суммой. | [I] Подписи малых сегментов и сравнение категорий остаются слабым местом; без текстовой легенды цвет становится единственным кодом. | [I] [E10] |
|
||||||
|
|
||||||
|
## Evidence & Sources
|
||||||
|
|
||||||
|
[F] **Проверенное ядро разрезов.** Текущие позиции, классы активов, секторы, география/регионы и валюты подтверждены хотя бы одним первичным продуктовым источником; эмитентская агрегация акций и облигаций дополнительно явно описана Т-Инвестициями [E1, E3, E6, E7].
|
||||||
|
|
||||||
|
[I] **Не универсальный стандарт.** Maturity buckets, кредитный рейтинг/risk level, вклад позиции в доход, top gainers/losers и HHI нельзя называть обязательным общим стандартом по этой выборке: либо найден только смежный отчёт, либо точный критерий не проверен.
|
||||||
|
|
||||||
|
[F] **Позиции по стоимости.** `PortfolioPositionDto` уже содержит `type`, `quantity`, `currentValue`, `displayName`, `ticker` и `absoluteReturn`; отдельных `sector`, `country`, `issuer`, `maturityDate` и `rating` в DTO нет [L4].
|
||||||
|
|
||||||
|
[I] **Акции и облигации — высокая готовность данных.** Рейтинг по `currentValue` внутри типа и выбранной валюты опирается на текущий snapshot; ограничения отсутствующих, нулевых или отрицательных оценок должны быть видимы, как и в существующей структуре [L3, L4].
|
||||||
|
|
||||||
|
[F] **Сектор и страна.** В интеграционных типах и proto T-Bank есть `sector`, `countryOfRisk`/`country_of_risk` и `currency`; `sector` присутствует у Share и Bond, а справка Т-Инвестиций предупреждает, что для российских бумаг встречаются отрасли и государственные ценные бумаги, а для иностранных — только отрасли [L5, L6, E1].
|
||||||
|
|
||||||
|
[I] **Сектор — средняя уверенность.** Поле существует upstream, но текущий Portfolio DTO его не exposes; полнота значений по реальным позициям владельца и применимость категории «государственные ценные бумаги» к секторному обзору не подтверждены [L4, L5, E1].
|
||||||
|
|
||||||
|
[I] **Страна/регион — кандидат на потом.** `countryOfRisk` доступен в интеграционных данных, но это страна риска, а не обязательно страна листинга, регистрации или валюты; регион потребует дополнительного смыслового отображения, которое в источниках не проверено [L5, L6].
|
||||||
|
|
||||||
|
[F] **Валюта.** Каноническая spec фиксирует Portfolio «без валютной конвертации» и итоги по валютам; существующая «Структура портфеля» уже показывает одну выбранную валюту за раз [L3, L8].
|
||||||
|
|
||||||
|
[I] **Валютный выбор — наиболее согласованный вариант.** Пер-валютный топ сохраняет сопоставимость сумм и существующий UX; единый топ или единый donut валютной аллокации по нескольким валютам требует общего знаменателя, то есть отдельного решения о конвертации и выхода за текущий узкий scope.
|
||||||
|
|
||||||
|
[F] **Эмитент.** В текущем DTO нет стабильного идентификатора эмитента; `displayName`, `isin` и `ticker` идентифицируют позицию/инструмент, но не доказывают принадлежность акции и облигации одной компании [L4].
|
||||||
|
|
||||||
|
[F] **Источник идентичности эмитента вне Portfolio Snapshot.** Полное описание актива T-Bank `AssetFull.brand` содержит бренд (`uid`, `name`, `company`, `sector`); у Share, Bond и Etf есть `asset_uid`, связывающий инструмент с активом. Краткое описание актива `Asset` бренда не содержит, а `BrandData` внутри инструментов хранит только данные логотипа [L9].
|
||||||
|
|
||||||
|
[I] **Эмитентская агрегация — полезный кандидат, источник идентичности есть.** Паттерн подтверждён Т-Инвестициями [E1]; бренд актива T-Bank (`AssetFull.brand`) даёт кандидата на идентичность эмитента вне текущего Portfolio Snapshot, где её нет [L4, L9]. Группировка по совпадающему имени остаётся ненадёжной эвристикой. Полнота бренда по позициям владельца не проверена [H].
|
||||||
|
|
||||||
|
[F] **Погашение и риск облигаций.** `TbankBond` содержит опциональные `maturityDate` и `riskLevel`; proto также описывает `perpetual_flag`, а текущий DTO этих полей не содержит [L4, L5, L6].
|
||||||
|
|
||||||
|
[I] **Maturity buckets возможны только для облигаций и требуют отдельной трактовки бессрочных бумаг и отсутствующих дат; `riskLevel` является полем T-Bank, а не подтверждённым кредитным рейтингом агентства [L5, L6].**
|
||||||
|
|
||||||
|
[F] **Кредитный рейтинг.** В проверенных текущих DTO, интеграционных типах и proto поле кредитного рейтинга не найдено; это наблюдение ограничено проверенными файлами, а не доказательством отсутствия любого рейтинга во всех продуктах T-Bank [L4, L5, L6].
|
||||||
|
|
||||||
|
[F] **Доход и P&L.** `absoluteReturn` уже есть у позиции, но текущий DTO не содержит дивидендный/купонный вклад; Portfolio Performance документирует отдельные performance и payments reports, а Т-Инвестиции отдельно описывают доходность и будущие выплаты [L4, E2, E8, E9].
|
||||||
|
|
||||||
|
[I] **Top gainers/losers по `absoluteReturn` технически ближе к MVP, чем income contribution, но отвечает на вопрос «что дало результат», а не «где сейчас деньги»; готовая постатейная история выплат в этом исследовании не проверена.**
|
||||||
|
|
||||||
|
[F] **Доступность цвета.** WCAG 2.2 SC 1.4.1 требует, чтобы цвет не был единственным визуальным средством передачи информации или различения элементов [E10].
|
||||||
|
|
||||||
|
[I] **Текстовая альтернатива обязательна для смысла диаграммы.** Название, сумма и доля в списке/легенде полезны не только для accessibility, но и для точного сравнения малых категорий [E10, L3].
|
||||||
|
|
||||||
|
[F] **Концентрация как риск.** FINRA описывает концентрацию как зависимость результата портфеля от одной позиции или группы связанных позиций и отмечает, что разные классы активов сами по себе не устраняют такую зависимость [E11].
|
||||||
|
|
||||||
|
[F] **HHI.** US DOJ определяет Herfindahl–Hirschman Index как сумму квадратов долей участников рынка; это источник математической формулы, а не нормы для личного инвестиционного портфеля [E12].
|
||||||
|
|
||||||
|
[I] **Доля топ-5 понятнее HHI для первого экрана.** Она прямо отвечает «сколько денег приходится на пять крупнейших позиций»; HHI и обратная величина `1 / Σw²` могут служить «эффективным числом позиций», но не имеют проверенного здесь персонального порога действия и создают риск ложной точности.
|
||||||
|
|
||||||
|
[I] **«Top-N + Прочее» сохраняет 100% выбранной совокупности и сокращает хвост, но скрывает состав «Прочего»; поэтому агрегат не заменяет полный список позиций.**
|
||||||
|
|
||||||
|
[F] **[R0]** Формулировка Change Owner и AS-IS контекст: Issue https://git.personal.ksv741.ru/WealthLedger/main/issues/48 (30.10.2026).
|
||||||
|
|
||||||
|
[F] **[L1]** `frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx` — вкладки страницы счёта, размещение `PortfolioStructure`, `PortfolioStructure.key`, переключатель типов позиций.
|
||||||
|
|
||||||
|
[F] **[L2]** `frontend/src/pages/account/model/positions.ts` — виды секций `stocks | bonds | other`, отображение доли и результата позиции.
|
||||||
|
|
||||||
|
[F] **[L3]** `frontend/src/widgets/portfolio-structure/ui/PortfolioStructure.view.tsx` — DonutChart, выбор валюты, tooltip, легенда, обработка неполных/отрицательных/неоценённых данных.
|
||||||
|
|
||||||
|
[F] **[L4]** `backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts` — поля позиции и структуры портфеля.
|
||||||
|
|
||||||
|
[F] **[L5]** `backend/src/integrations/tbank/tbank-market.types.ts` — `sector`, `countryOfRisk`, `currency`, `maturityDate`, `riskLevel` и другие поля инструментов.
|
||||||
|
|
||||||
|
[F] **[L6]** `backend/src/integrations/tbank/proto/contracts/instruments.proto` — описание полей Share, Bond и связанных инструментов T-Bank.
|
||||||
|
|
||||||
|
[F] **[L7]** `backend/src/modules/investments/instruments/application/public-instrument.ts` — использование instrument metadata в публичном каталоге акций/облигаций, включая `sector`, `maturityDate`, `riskLevel`.
|
||||||
|
|
||||||
|
[F] **[L8]** `specs/openspec/specs/portfolio-view/spec.md` — Portfolio Snapshot одного счёта, отсутствие валютной конвертации и итоги по валютам.
|
||||||
|
|
||||||
|
[F] **[L9]** `backend/src/integrations/tbank/proto/contracts/instruments.proto` — `AssetFull.brand` (~:1051-1067), `Brand` (:1220-1229), `asset_uid` у Share (:776), Bond (:543), Etf (:657); `Asset` (:1075-1080) без бренда; `BrandData` (`common.proto:120`) — только данные логотипа.
|
||||||
|
|
||||||
|
[F] **[E1]** Т-Банк, «Аналитика инвестиционного портфеля»: https://www.tbank.ru/invest/help/educate/yield-analysis/about/folio-lookup/ — структура портфеля по классам активов, секторам, валютам и компаниям.
|
||||||
|
|
||||||
|
[F] **[E2]** Т-Банк, «Доходность портфеля и аналитика в Т-Инвестициях»: https://www.tbank.ru/invest/help/educate/yield-analysis/ — стоимость, прирост, дивиденды, купоны и будущие выплаты.
|
||||||
|
|
||||||
|
[F] **[E3]** Intelinvest, официальная продуктовая страница: https://intelinvest.ru/ — заявленные разрезы структуры и аналитический доход; маркетинговый источник.
|
||||||
|
|
||||||
|
[F] **[E5]** Snowball Income, «Возможности»: https://snowball-income.com/overview — заявленная ребалансировка по секторам и типам активов; маркетинговый источник.
|
||||||
|
|
||||||
|
[F] **[E6]** Portfolio Performance Manual, «Statement of Assets»: https://help.portfolio-performance.info/en/reference/view/reports/statement/ — таблица, доли, donut, treemap и классификации.
|
||||||
|
|
||||||
|
[F] **[E7]** Portfolio Performance Manual, «Classifying your assets»: https://help.portfolio-performance.info/en/getting-started/classify-assets/ — taxonomy, asset class, sector, region, industry и ручная классификация.
|
||||||
|
|
||||||
|
[F] **[E8]** Portfolio Performance Manual, «Securities»: https://help.portfolio-performance.info/en/reference/view/reports/performance/securities/ — стоимость, прирост, прибыль/убыток и доходы по бумагам.
|
||||||
|
|
||||||
|
[F] **[E9]** Portfolio Performance Manual, «Payments»: https://help.portfolio-performance.info/en/reference/view/reports/performance/payments/ — выплаты по инструментам и суммы по типам платежей.
|
||||||
|
|
||||||
|
[F] **[E10]** W3C WAI, «Understanding SC 1.4.1: Use of Color»: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html — цвет не должен быть единственным носителем смысла.
|
||||||
|
|
||||||
|
[F] **[E11]** FINRA, «Concentrate on Concentration Risk»: https://www.finra.org/investors/insights/concentration-risk — риск зависимости от позиции или связанных позиций.
|
||||||
|
|
||||||
|
[F] **[E12]** US Department of Justice, «Herfindahl-Hirschman Index»: https://www.justice.gov/atr/herfindahl-hirschman-index — формула HHI и её исходная предметная область.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
[H] Под «крупнейшими» владелец имеет в виду наибольшую положительную `currentValue`, а не количество бумаг, прибыль, вес от всех счетов или целевую долю.
|
||||||
|
|
||||||
|
[H] Топ-5 рассматривается внутри одного Brokerage Account и одной выбранной валюты; включение cash и других счетов запросом не подтверждено.
|
||||||
|
|
||||||
|
[H] «Прочее» означает сумму позиций вне первых пяти, а не существующий тип позиций `other`; это разные понятия, которые нельзя смешивать без пояснения [L2].
|
||||||
|
|
||||||
|
[H] Владелец прежде всего хочет обзор текущей концентрации, а не рекомендации по ребалансировке, прогноз дохода или оценку кредитного качества.
|
||||||
|
|
||||||
|
[H] Фактическая полнота `sector`, `countryOfRisk`, `maturityDate` и `riskLevel` по позициям владельца неизвестна; реальных snapshot-данных в исследовании нет.
|
||||||
|
|
||||||
|
[H] Предпочтение «классное и красивое» не проверено отдельно; выбор конкретной диаграммы остаётся эстетической гипотезой, а не подтверждённой потребностью.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
[I] **Смешение валют.** Общий рейтинг без конвертации сравнит несопоставимые денежные единицы; конвертация изменит смысл канонического portfolio-view и расширит решение за пределы отображения топ-5 [L8].
|
||||||
|
|
||||||
|
[I] **Дублирование.** Новый donut по классам активов повторит существующий `PortfolioStructure`; название «Структура» дополнительно сделает границу двух блоков неочевидной [L1, L3].
|
||||||
|
|
||||||
|
[I] **Ложная полнота секторов.** Отсутствующий sector, специальная категория госбумаг и неприменимость сектора к части типов могут исказить доли, если не показывать неизвестную категорию [E1, L4].
|
||||||
|
|
||||||
|
[I] **Ошибочная идентичность эмитента.** Объединение по названию или тикеру может разделить связанные бумаги или объединить разные инструменты; текущий snapshot не содержит issuer identity [L4]; кандидат на стабильную идентичность — `AssetFull.brand` [L9].
|
||||||
|
|
||||||
|
[I] **Смешение задач.** Стоимость, unrealized P&L, исторические выплаты и будущие купоны имеют разные знаменатели и временной смысл; один безымянный переключатель критериев может создать видимость сопоставимости [E2, E8, E9].
|
||||||
|
|
||||||
|
[I] **Перегрузка MVP.** Страны, эмитенты, сроки, риск, доход, HHI и несколько типов диаграмм одновременно увеличат число непроверенных решений без данных об их ценности для единственного владельца.
|
||||||
|
|
||||||
|
[I] **HHI без действия.** Перенос антимонопольных порогов HHI на личный портфель был бы необоснованным; даже корректное число не подсказывает владельцу, какая концентрация приемлема именно для него [E11, E12].
|
||||||
|
|
||||||
|
[I] **Color-only и скрытый хвост.** Диаграмма без подписей нарушит принцип WCAG 1.4.1, а «Прочее» без суммы/доли и доступа к полному списку скроет значимую часть риска [E10].
|
||||||
|
|
||||||
|
## Conclusions
|
||||||
|
|
||||||
|
[I] **Высокая уверенность:** непосредственно запрошенные топ-5 акций и облигаций по текущей стоимости — самый обоснованный MVP: данные уже есть, результат не дублирует класс активов и решает конкретную задачу концентрации [L4].
|
||||||
|
|
||||||
|
[I] **Средняя уверенность:** секторный «Top-5 + Прочее» соответствует и запросу, и проверенным продуктовым паттернам, но качество зависит от непроверенной полноты upstream-данных [E1, E3, E6, L5].
|
||||||
|
|
||||||
|
[I] **Высокая уверенность:** выбор одной валюты за раз — единственный найденный вариант, который одновременно сохраняет текущий UX и каноническое отсутствие конвертации; общий мультивалютный топ без нового правила конвертации некорректен [L3, L8].
|
||||||
|
|
||||||
|
[I] **Средняя уверенность:** для топ-позиций ranked horizontal bar/list информативнее donut; donut допустим для секторных долей, если рядом есть текстовые суммы и доли, а существующий donut по классам активов не повторяется [L3, E10].
|
||||||
|
|
||||||
|
[I] **Средняя уверенность:** доля топ-5 — полезная простая метрика концентрации; HHI и эффективное число позиций лучше отложить, поскольку персональная интерпретируемость и порог действия не подтверждены [E11, E12].
|
||||||
|
|
||||||
|
[I] **Средняя уверенность:** следующими кандидатами логичны эмитентская концентрация и страна риска, а не кредитный рейтинг: первые подтверждены внешними паттернами и частично данными, второй не найден в проверенном текущем источнике [E1, E3, L4–L6].
|
||||||
|
|
||||||
|
[I] **Низкая уверенность в широте «рыночного стандарта»:** выборка первичных источников небольшая, несколько названных продуктов не проверены, а маркетинговые страницы описывают заявления производителей, а не фактическое использование.
|
||||||
|
|
||||||
|
[I] **Размещение:** рабочее имя «Концентрация» точнее охватывает позиции и секторы, чем «Топ-5»; «Структура» хуже из-за существующего одноимённого по смыслу виджета, а отдельная вкладка рядом с «Позициями» соответствует исходному запросу владельца [R0, L1, L3].
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
[I] **Сузить scope до одного обзора текущей концентрации в выбранной валюте, а не строить универсальную аналитику портфеля.** Уверенность: высокая для стоимостного топа, средняя для секторов, низкая для дополнительных разрезов.
|
||||||
|
|
||||||
|
[I] **Must — рекомендуемый узкий MVP:** топ-5 акций и топ-5 облигаций по `currentValue`; топ-5 секторов плюс «Прочее»; сумма и доля каждой строки; доля совокупного топ-5; выбор одной валюты; ranked bar/list с текстовыми подписями и одной distribution-визуализацией без повторения классов активов.
|
||||||
|
|
||||||
|
[I] **Условие уверенности для секторов:** если реальные данные окажутся слишком неполными, секторный критерий разумно перенести в later, не подменяя неизвестные значения догадками; сам стоимостной топ от этого не зависит [L4, L5].
|
||||||
|
|
||||||
|
[I] **Later:** страна риска; агрегация акций и облигаций одного эмитента (на момент research — после подтверждения issuer identity; позднее Change Owner перенёс в MVP, так как источник найден в `AssetFull.brand` [L9]); maturity buckets и `riskLevel` только для облигаций; top gainers/losers как отдельный смысловой режим; income contribution после подтверждения истории выплат; HHI/эффективное число позиций после проверки понятности владельцу.
|
||||||
|
|
||||||
|
[I] **No для этого изменения:** кредитный рейтинг как обещанный критерий; скрытая валютная конвертация; повтор donut по классам активов; treemap только ради визуального эффекта; одновременный запуск всех перечисленных критериев.
|
||||||
|
|
||||||
|
[I] **Размещение:** предпочтительна отдельная вкладка рядом с «Позициями» с рабочим названием «Концентрация»; решение изменится, если владелец хочет именно короткий рейтинг без секторов — тогда название «Топ-5» станет точнее.
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 1:** секторы нужны только по акциям или по всем инструментам, для которых T-Bank возвращает sector, включая облигации и категорию государственных бумаг?
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 2:** подтверждает ли владелец пер-валютный показ вместо единого рейтинга с конвертацией, и какая валюта должна выбираться первоначально?
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 3:** входят ли в топ только позиции с известной положительной стоимостью и нужно ли отдельно показывать число неоценённых позиций?
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 4:** достаточно ли пяти строк плюс «Прочее», или владельцу важно раскрывать хвост без возврата к полному списку?
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 5:** новый блок должен оставаться отдельным от текущей «Структуры портфеля» или в перспективе заменить/поглотить её?
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 6:** нужна ли сейчас только текущая концентрация, или владелец ожидает также top gainers/losers и вклад дивидендов/купонов?
|
||||||
|
|
||||||
|
[H] **Открытый вопрос 7:** какие секторы и категории реально присутствуют в snapshot владельца и какая доля стоимости остаётся без sector?
|
||||||
@@ -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` |
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Test Cases — conc (вкладка «Концентрация», разрезы, доли)
|
||||||
|
|
||||||
|
Cases пишутся только там, где нужны данные, шаги или oracle сверх Scenario;
|
||||||
|
остальные строки Coverage Matrix в `../qa-analysis.md` ссылаются на Scenarios
|
||||||
|
Delta Specs `portfolio-concentration` (PC) и `portfolio-view` (PVΔ).
|
||||||
|
|
||||||
|
### TC-CONC-002 Вкладка в состояниях страницы
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Вкладка в состояниях страницы` / все 3 Scenario
|
||||||
|
- **Risk / Priority:** средний / high — вкладка не должна появляться при загрузке, ошибке и пустом Portfolio, но должна при Cash Balance
|
||||||
|
- **Preconditions:** тестовый пользователь с тремя счетами (см. данные); frontend с управляемым API-моком
|
||||||
|
- **Test Data:** счёт A — Portfolio пуст (нет позиций и остатков); счёт B — только Cash Balance 1 000 RUB; счёт C — одна акция; мок: GET `/portfolio/{C}` сначала pending, потом 500
|
||||||
|
- **Steps:**
|
||||||
|
1. Открыть счёт A → проверить набор вкладок.
|
||||||
|
2. Открыть счёт B → открыть вкладку «Концентрация».
|
||||||
|
3. Открыть счёт C при pending GET и при ответе 500.
|
||||||
|
- **Expected Results:** A — вкладки «Концентрация» нет. B — вкладка есть, разрез «Акции» в пустом состоянии («Акции», RUB не показан — валют нет). C — в обоих случаях вкладки нет.
|
||||||
|
- **Automation Candidate:** yes (component)
|
||||||
|
|
||||||
|
### TC-CONC-005 Сброс состояния при новом заходе и смене счёта
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Начальное состояние вкладки` (норма + Scenario «Новый заход»)
|
||||||
|
- **Risk / Priority:** средний / high — ошибочный persist состояния между заходами даёт чужой разрез/валюту
|
||||||
|
- **Preconditions:** счёт с позициями в RUB и USD (доступные валюты: RUB, USD — первая RUB)
|
||||||
|
- **Test Data:** портфель из TC-CONC-007; на вкладке выбраны «Секторы» и USD
|
||||||
|
- **Steps:**
|
||||||
|
1. Открыть счёт, вкладку «Концентрация», выбрать «Секторы», валюту USD.
|
||||||
|
2. Перезагрузить страницу → открыть вкладку.
|
||||||
|
3. Уйти со страницы счёта (список счетов) и вернуться → открыть вкладку.
|
||||||
|
4. Переключить счёт на счёт C (одна акция) → открыть вкладку.
|
||||||
|
- **Expected Results:** после шагов 2–4: разрез «Акции», выбрана первая валюта списка (RUB для этого счёта; для счёта C — валюта его акции).
|
||||||
|
- **Automation Candidate:** yes (e2e)
|
||||||
|
|
||||||
|
### TC-CONC-006 Сохранение состояния при переключении вкладок страницы
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Начальное состояние вкладки` («Пока User остаётся на странице… разрез и валюта SHALL сохраняться»)
|
||||||
|
- **Risk / Priority:** низкий / medium — поведение внутри одной сессии страницы
|
||||||
|
- **Preconditions:** как TC-CONC-005
|
||||||
|
- **Steps:**
|
||||||
|
1. Вкладка «Концентрация»: выбрать «Облигации», USD.
|
||||||
|
2. Переключиться на вкладку страницы «Позиции».
|
||||||
|
3. Вернуться на «Концентрацию».
|
||||||
|
- **Expected Results:** открыт «Облигации» в USD.
|
||||||
|
- **Automation Candidate:** yes (e2e; объединяется с TC-CONC-005 в один прогон)
|
||||||
|
|
||||||
|
### TC-CONC-007 Состав разрезов по таблице типов
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Состав разрезов` (все ветви таблицы) + `Расчёт долей`
|
||||||
|
- **Risk / Priority:** высокий / high — фильтр типов и агрегация определяют все цифры вкладки
|
||||||
|
- **Preconditions:** вкладка открыта, валюта RUB
|
||||||
|
- **Test Data (RUB, один счёт):** S1 `STOCK` 400, sector `energy`, issuer `b1` «Сбер»; S2 `STOCK` 100, sector `energy`, issuer `b1` «Сбербанк»; B1 `BOND` 100, sector `energy`, issuer `b1` «Сбер»; F1 `FUND` 300, sector `financial`, issuer `b1`; C1 `CURRENCY` 50; O1 `OTHER` 10
|
||||||
|
- **Steps:**
|
||||||
|
1. Разрез «Акции» → суммы и доли.
|
||||||
|
2. Разрез «Облигации».
|
||||||
|
3. Разрез «Секторы».
|
||||||
|
4. Разрез «Эмитенты».
|
||||||
|
- **Expected Results (расчёт):**
|
||||||
|
- Акции: S1 400 ₽ «80 %», S2 100 ₽ «20 %» (база 500 = 400+100; 400/500 = 80 %, 100/500 = 20 %).
|
||||||
|
- Облигации: B1 100 ₽ «100 %».
|
||||||
|
- Секторы: «Энергетика» 600 ₽ (400+100+100) «66,7 %», «Финансы» 300 ₽ «33,3 %» (база 900; 600/900 = 66,666… % → «66,7 %», 300/900 = 33,333… % → «33,3 %»); C1 и O1 не входят.
|
||||||
|
- Эмитенты: «Сбер» 600 ₽ «100 %» (400+100+100; фонд F1 исключён; имя — по наибольшей позиции S1 400 > B1 100 → «Сбер»).
|
||||||
|
- **Automation Candidate:** yes (component, вычислитель разреза)
|
||||||
|
|
||||||
|
### TC-CONC-008 Цепочка названия элемента name → ticker → FIGI
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Названия элементов` + `portfolio-view` — `Нормализованный состав Portfolio` (отображаемый идентификатор)
|
||||||
|
- **Risk / Priority:** средний / medium — пустое название ломает список
|
||||||
|
- **Test Data:** акция A с name «Газпром»; акция B без name, ticker `SBER`; акция C без name и ticker, FIGI `BBG000123456`; позиция без issuer в «Эмитентах»
|
||||||
|
- **Steps:**
|
||||||
|
1. Разрез «Акции»: прочитать названия трёх строк.
|
||||||
|
2. Разрез «Эмитенты» для позиции без issuer.
|
||||||
|
- **Expected Results:** строки названы «Газпром», `SBER`, `BBG000123456`; в «Эмитентах» — «Эмитент не указан».
|
||||||
|
- **Automation Candidate:** yes (component)
|
||||||
|
|
||||||
|
### TC-CONC-015 Независимость выбора валюты и fallback при исчезновении
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Независимый выбор валюты вкладки` (2 Scenario)
|
||||||
|
- **Risk / Priority:** средний / high — общий Select протекает между виджетами; пропавшая валюта оставляет битое состояние
|
||||||
|
- **Preconditions:** позиции в RUB и USD; «Структура портфеля» на той же странице
|
||||||
|
- **Steps:**
|
||||||
|
1. В «Структуре портфеля» выбрать USD; на вкладке «Концентрация» выбрать RUB.
|
||||||
|
2. В «Структуре портфеля» сменить USD → RUB → USD.
|
||||||
|
3. Обновить Portfolio моком так, чтобы позиций в RUB не осталось (только USD-акции).
|
||||||
|
- **Expected Results:** после шага 2 на вкладке по-прежнему RUB. После шага 3 выбрана первая валюта нового списка — USD (единственная), разрез пересчитан по USD-позициям.
|
||||||
|
- **Automation Candidate:** yes (component + e2e)
|
||||||
|
|
||||||
|
### TC-CONC-016 Границы 5/6/7 элементов и счётчик «Прочее»
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Топ-5 и строка «Прочее»`, `Суммарная доля топ-5`
|
||||||
|
- **Risk / Priority:** высокий / high — граничные значения и формулировка числа элементов
|
||||||
|
- **Preconditions:** возможность подмены портфеля (мок/API-фикстура)
|
||||||
|
- **Test Data (RUB, суммы):** набор-1: 400, 250, 150, 100, 50, 30, 20; набор-2: 300, 250, 200, 150, 60, 40; набор-3: 300, 250, 200, 150, 100; набор-4: одна акция 100; набор-5: 12 акций, из них 7 в «Прочее»
|
||||||
|
- **Steps:** для каждого набора открыть разрез «Акции», прочитать строки, счётчик «Прочее» и центр диаграммы.
|
||||||
|
- **Expected Results (расчёт):**
|
||||||
|
- Набор-1 (сумма 1 000): 5 строк + «Прочее» 50 ₽ (2 элемента) «5 %»; доли строк «40 %», «25 %», «15 %», «10 %», «5 %»; центр «Топ-5 · 95 %» и «1 000 ₽».
|
||||||
|
- Набор-2 (сумма 1 000): 6 строк без «Прочее», доли «30 %», «25 %», «20 %», «15 %», «6 %», «4 %»; центр «Топ-5 · 96 %».
|
||||||
|
- Набор-3 (сумма 1 000): 5 строк, доли «30 %», «25 %», «20 %», «15 %», «10 %», «Прочее» нет; центр без «Топ-5».
|
||||||
|
- Набор-4: одна строка «100 %», центр только сумма.
|
||||||
|
- Набор-5: счётчик «Прочее» — «7 элементов»; доли по фактическим суммам.
|
||||||
|
- **Automation Candidate:** yes (component)
|
||||||
|
|
||||||
|
### TC-CONC-020 Формат и округление показанных долей
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Показ долей` (все ветви нормы)
|
||||||
|
- **Risk / Priority:** средний / high — владелец сверяет доли с суммами; неверный формат подрывает доверие
|
||||||
|
- **Preconditions:** разрез «Акции», RUB; управляемый портфель (мок/API-фикстура)
|
||||||
|
- **Test Data (RUB, суммы):** набор-A: 1 и 15; набор-B: 100 000 000 и 1; набор-C: 100, 100, 100
|
||||||
|
- **Steps:** для каждого набора прочитать показанные доли строк и их сумму.
|
||||||
|
- **Expected Results (расчёт):**
|
||||||
|
- Набор-A (база 16): точные доли 1/16 = 6,25 % и 15/16 = 93,75 %. Округление до десятых, середина вверх: «6,3 %» и «93,8 %». Сумма показанных 6,3 + 93,8 = 100,1 % ≠ 100 % — допустимо (компенсации нет).
|
||||||
|
- Набор-B (база 100 000 001): 100 000 000/100 000 001 = 99,999999 % → «100 %» (без «,0»); 1/100 000 001 = 0,000001 % < 0,1 % → «< 0,1 %».
|
||||||
|
- Набор-C: 100/300 = 33,333… % → у всех трёх «33,3 %» (сумма показанных 99,9 % — допустимо).
|
||||||
|
- **Automation Candidate:** yes (component)
|
||||||
|
|
||||||
|
### TC-CONC-024 Полный проход таблицы подписей секторов
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Русские подписи известных секторов` (все 11 строк таблицы)
|
||||||
|
- **Risk / Priority:** низкий / medium — таблица — эталон, одна ошибка портит разрез
|
||||||
|
- **Test Data:** 11 позиций (BOND/STOCK/FUND) секторов `consumer`, `energy`, `financial`, `health_care`, `industrials`, `it`, `materials`, `real_estate`, `telecom`, `utilities`, `government` по 1 ₽ каждая — доли не важны, важны названия
|
||||||
|
- **Steps:** открыть «Секторы», сверить каждую строку с таблицей PC:374.
|
||||||
|
- **Expected Results:** подписи точно по таблице: Потребительский сектор, Энергетика, Финансы, Здравоохранение, Промышленность, Информационные технологии, Материалы, Недвижимость, Телекоммуникации, Коммунальные услуги, Государственный сектор.
|
||||||
|
- **Automation Candidate:** yes (component, table-driven)
|
||||||
|
|
||||||
|
### TC-CONC-027 Доступность диаграммы скринридером и без цвета
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Доступность диаграммы без цвета`
|
||||||
|
- **Risk / Priority:** средний / high (a11y) — информация только цветом недопустима
|
||||||
|
- **Preconditions:** набор-2 из TC-CONC-016; VoiceOver (или NVDA); тема светлая и тёмная
|
||||||
|
- **Steps:**
|
||||||
|
1. Прочитать диаграмму скринридером в свёрнутом состоянии.
|
||||||
|
2. Раскрыть «Прочее» (набор-1) и прочитать снова.
|
||||||
|
3. Клавиатурой: Tab к переключателям разрезов и строкам, Enter на «Прочее».
|
||||||
|
4. Сравнить контраст сегментов и подписей в обеих темах с макетами ux-design v2.
|
||||||
|
- **Expected Results:** скринридер читает для каждого сегмента название, сумму и долю в порядке списка (для набор-2: шесть пар «название — N ₽ — N %»); раскрытие не меняет прочитанный состав (6 сегментов); Enter/Space раскрывают «Прочее», focus видим; контраст соответствует макетам.
|
||||||
|
- **Automation Candidate:** no (ручная/visual; текстовый эквивалент — component)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Test Cases — meta (sector/issuer enrichment, Snapshot-инварианты)
|
||||||
|
|
||||||
|
Cases добавляют данные/порядок событий/oracle сверх Scenarios Delta Specs
|
||||||
|
`portfolio-view` (PVΔ). Требуют управляемое время обхода (час) и управляемые
|
||||||
|
ответы T-Bank (integration, configured HTTP tests, моки по proto).
|
||||||
|
|
||||||
|
### TC-META-002 Token-отказ при получении сектора/бренда не маскируется fallback
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot` (исключение token) / Scenario «Отказ token при получении бренда»
|
||||||
|
- **Risk / Priority:** высокий / high — invalid token, замаскированный старым Snapshot, скрывает от владельца потерю доступа
|
||||||
|
- **Preconditions:** сохранённый полный Snapshot старше 60 минут; подключение с invalid token; GET выполняется после обхода
|
||||||
|
- **Test Data:** мок T-Bank: GetPortfolio/GetPositions — PERMISSION_DENIED; типизированный инструмент (sector) — UNAUTHENTICATED
|
||||||
|
- **Steps:**
|
||||||
|
1. Дождаться обхода (или вызвать принудительно в тесте).
|
||||||
|
2. GET Portfolio пользователем.
|
||||||
|
3. Повторить с отказом только на `GetAssetBy` (бренд), финансы успешны.
|
||||||
|
- **Expected Results:** шаг 2 — `400` Problem Details, code `INVALID_TBANK_TOKEN`, fallback Snapshot не возвращается. Шаг 3 — тот же `400`/`INVALID_TBANK_TOKEN`: отказ token на любом звене сектора/бренда приравнен к отказу обхода (требование «Свежесть и fallback полного Portfolio Snapshot»), хотя финансы могли быть получены.
|
||||||
|
- **Automation Candidate:** yes (integration)
|
||||||
|
|
||||||
|
### TC-META-003 Суточный цикл обновления сектора
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-view` — `Обновление сектора и эмитента` / Scenario «Обход через 24 часа» + 24h-отсчёт таблицы `Результат получения сектора и эмитента`
|
||||||
|
- **Risk / Priority:** высокий / high — ошибка отсчёта даёт вечное старое значение или вечный ре-fetch
|
||||||
|
- **Preconditions:** управляемое время обхода (шаг «час»); позиция P; T-Bank меняет сектор P на `financial` сразу после T0
|
||||||
|
- **Steps (линейная шкала):**
|
||||||
|
1. T0: обход — успех, `sector: energy` (отсчёт с T0).
|
||||||
|
2. T0+1h … T0+23h: обходы — обращений за сектором нет.
|
||||||
|
3. T0+24h: обход запрашивает сектор, T-Bank отвечает UNAVAILABLE.
|
||||||
|
4. T0+25h: обход запрашивает сектор (ошибка не сбросила отсчёт), T-Bank отвечает `financial`.
|
||||||
|
5. T0+26h … T0+48h: обходы.
|
||||||
|
6. T0+49h: обход.
|
||||||
|
- **Expected Results:**
|
||||||
|
- Шаги 2: `sector: energy`.
|
||||||
|
- Шаг 3: `sector: energy` (последнее известное), отсчёт не сброшен (идёт от T0) — поэтому шаг 4 снова обращается.
|
||||||
|
- Шаг 4: `sector: financial`, отсчёт с T0+25h.
|
||||||
|
- Шаги 5: `sector: financial`, обращений нет (T0+25h+24h = T0+49h).
|
||||||
|
- Шаг 6: первое обращение после успеха — сектор запрошен.
|
||||||
|
- **Automation Candidate:** yes (integration, управляемое время)
|
||||||
|
|
||||||
|
### TC-META-004 Четыре исхода получения: поле и отсчёт 24 часов
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-view` — `Результат получения сектора и эмитента` (каждая строка таблицы) / Scenarios «Ответ без значения заменяет известное значение», «"Не найдено" перезапускает отсчёт», «Ответ о другом инструменте», «Недоступность T-Bank сохраняет известное значение», «Значение ещё не известно»
|
||||||
|
- **Risk / Priority:** высокий / high — таблица — ядро нового контракта; классификация NOT_FOUND решает поведение
|
||||||
|
- **Preconditions:** 4 позиции P1–P4, у каждой известен `sector: energy` (успех в T0); управляемое время; моки по proto
|
||||||
|
- **Test Data:** P1 → ответ со значением `it`; P2 → NOT_FOUND (и вариант «пустой объект без sector» отдельным прогоном); P3 → ответ с чужим `asset_uid`/UID; P4 → UNAVAILABLE (timeout)
|
||||||
|
- **Steps:**
|
||||||
|
1. В T0+24h провести обход со всеми четырьмя моками.
|
||||||
|
2. GET Portfolio, прочитать `sector` P1–P4.
|
||||||
|
3. В T0+25h: P2 теперь отвечает значением `financial`; P3 и P4 — тоже успех `financial`.
|
||||||
|
4. Обход T0+25h; GET; прочитать `sector`.
|
||||||
|
5. Обход T0+48h: P1 и P2 — успех `financial`; P3 и P4 — `energy`.
|
||||||
|
- **Expected Results:**
|
||||||
|
- Шаг 2: P1 `it` (отсчёт сброшен на T0+24h), P2 `null` (сброшен на T0+24h), P3 `energy` (не сброшен, retry), P4 `energy` (не сброшен, retry).
|
||||||
|
- Шаг 4: P2 — по правилу `Обновление сектора и эмитента` значение появляется только при обходе ≥24 ч после «не найдено» (T0+24h), поэтому в T0+25h остаётся `null`; P3 и P4 — `financial` (получено, сброс отсчёта на T0+25h).
|
||||||
|
- Шаг 5: P1 — запрошен снова (отсчёт сброшен в T0+24h, T0+48h ≥ 24 ч), становится `financial`; P2 — запрошен (≥24 ч от «не найдено»), становится `financial`; P3 и P4 — не запрашиваются (последний успех T0+25h, T0+48h < T0+49h), `sector: financial` остаётся.
|
||||||
|
- **Automation Candidate:** yes (integration; P2-ветка — минимальное воспроизведение перезапуска отсчёта)
|
||||||
|
|
||||||
|
### TC-META-005 Смена и удаление подключения: sector/brand не протекают
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-view` — `Свежесть и fallback` («прежние личные datasets недоступны…»), failure-model F11
|
||||||
|
- **Risk / Priority:** высокий / high — утечка данных между подключениями
|
||||||
|
- **Preconditions:** пользователь, подключения K1 (счёт A, позиции с sector/brand) и K2 (счёт B)
|
||||||
|
- **Steps:**
|
||||||
|
1. Заменить K1 на новое подключение K1' (новый token, те же счёта): GET счёта A.
|
||||||
|
2. Удалить K1': GET счёта A.
|
||||||
|
3. Поздняя публикация обхода K1 (симуляция задержанной попытки) — проверить счёт A и B.
|
||||||
|
- **Expected Results:** шаг 1 — 404/409/400 по контракту замены, прежние datasets K1 недоступны; sector/brand позиции не выдаются до первого успешного обхода K1'. Шаг 2 — 404. Шаг 3 — публикация отклонена (`id`/`attemptId`/`running`), в Portfolio K2 и нового K1' данные K1 не появляются.
|
||||||
|
- **Automation Candidate:** yes (integration)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Test Cases — reg (регрессия затронутых областей)
|
||||||
|
|
||||||
|
Проверки сохранения существующего поведения, изменённого рисками change.
|
||||||
|
|
||||||
|
### TC-REG-001 Существующая вкладка «Позиции» неизменна
|
||||||
|
|
||||||
|
- **Verifies:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта` («SHALL NOT менять вкладку по умолчанию и содержание остальных вкладок»); регрессионный baseline canonical `portfolio-view`
|
||||||
|
- **Risk / Priority:** высокий / high — новая вкладка не должна ломать существующий экран
|
||||||
|
- **Preconditions:** счёт с позициями и Cash Balance; вкладка «Позиции» открывается по умолчанию в canonical-поведении
|
||||||
|
- **Steps:**
|
||||||
|
1. Открыть страницу счёта: какая вкладка активна, состав и порядок вкладок.
|
||||||
|
2. Пройтись по каждой прежней вкладке: содержимое и сортировка списка позиций, валютная сводка.
|
||||||
|
3. Режим «Все счета»: проверить отсутствие «Концентрации» и целостность прежних вкладок.
|
||||||
|
- **Expected Results:** по умолчанию открыта прежняя вкладка («Позиции»); «Концентрация» — рядом, последней из новых; содержание остальных вкладок и «Всех счетов» идентично canonical (сравнение по эталонным данным).
|
||||||
|
- **Automation Candidate:** yes (e2e + существующий regression-прогон)
|
||||||
|
|
||||||
|
### TC-REG-004 Часовой обход: sector/brand не влияют на успех и нагрузку синхронизации
|
||||||
|
|
||||||
|
- **Verifies:** `hourly-investment-sync` (canonical) + PVΔ `Свежесть и fallback` / F07, F10, F14
|
||||||
|
- **Risk / Priority:** высокий / high — новые RPC-вызовы в обходе могут ломать или замедлять финансовую синхронизацию
|
||||||
|
- **Preconditions:** тестовое подключение; счёт из TC-CONC-007 (6 позиций, смешанные секторы/бренды); метрики `tbank.rpc.*`
|
||||||
|
- **Steps:**
|
||||||
|
1. Обход при полностью успешных ответах: события `tbank.rpc.*`, успех публикации, `lastSuccessfulAt`.
|
||||||
|
2. Обход с тотальным сбоем sector/brand (UNAVAILABLE на все инструментальные вызовы), финансы успешны.
|
||||||
|
3. Обход с RESOURCE_EXHAUSTED на `GetAssetBy` (после повторов клиента).
|
||||||
|
4. Обход, где финансовые ответы не получены, но sector/brand успешны.
|
||||||
|
- **Expected Results:** шаг 1 — Snapshot опубликован, `isStale: false`. Шаги 2–3 — Snapshot опубликован, успех/свежесть финансов не изменены («Результат получения сектора и эмитента»: неуспешное получение SHALL NOT мешать обновлению Portfolio), sector/brand — прежние известные. Шаг 4 — публикации нет, прежний Snapshot с `isStale: true` (F01: успех sector/brand не заменяет финансовый). Число новых RPC-вызовов за обход фиксируется как baseline для Q-F3.
|
||||||
|
- **Automation Candidate:** yes (integration)
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# UX Design
|
||||||
|
|
||||||
|
Visual Package: **v2** (2026-10-30), локальные SVG в `../visuals/`, хеши в разделе «Materials». v1 (четыре макета без центральной суммы, предупреждения и без разрезов с одной валютой) заменён.
|
||||||
|
Базис существующих паттернов (код, не визуальные материалы): `frontend` @ `71126c91f0b30f4d65a313ddc7d7f86963340455`, Mantine / `@mantine/charts` 9.7.3, `docs/design/overview.md`. Storybook не запускался, истории прочитаны как исходники.
|
||||||
|
|
||||||
|
Поведение определяют Delta Specs `portfolio-concentration` (28 Requirements, с учётом решений владельца [#48, 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) и `portfolio-view`. Документ ссылается на Requirements и не вводит нового поведения; всё, чего в Specs нет, вынесено в «Design-level items».
|
||||||
|
|
||||||
|
## User Goals
|
||||||
|
|
||||||
|
Единственный пользователь — владелец, который отвечает на вопрос «где сконцентрированы мои деньги» и сравнивает вклад бумаг, секторов и эмитентов. Цель владельца: «сделать что-то классное и красивое» без выхода за пределы дизайн-системы.
|
||||||
|
|
||||||
|
| Цель | Что должно быть видно за один взгляд |
|
||||||
|
| --- | --- |
|
||||||
|
| Понять степень концентрации | «Топ-5 · N %» и сумма разреза в центре кольца |
|
||||||
|
| Узнать, кто в топе | Ранжированный список с названием, суммой, долей и длиной полосы |
|
||||||
|
| Не потерять остальное | Строка «Прочее» с числом элементов и раскрытием |
|
||||||
|
| Доверять цифрам | Одна валюта, предупреждение «Данные неполные», сноска «Не учтено позиций: N», одинаковые значения в кольце и списке |
|
||||||
|
|
||||||
|
## User Flows
|
||||||
|
|
||||||
|
Требования: `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`, `Вкладка в состояниях страницы`, `Переключение разрезов`, `Начальное состояние вкладки`, `Одна валюта за раз`, `Доступные валюты вкладки`, `Раскрытие строки «Прочее»`, `Строки без переходов`, `Сноска о не учтённых позициях`, `Предупреждение о неполных данных`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Страница счёта, вкладка «Позиции»] -->|Клик / Enter на «Концентрация»| B[Разрез «Акции», первая валюта по ISO]
|
||||||
|
B --> C{Что делает User}
|
||||||
|
C -->|Выбирает другой разрез| D[Разрез заменён, валюта сохранена, «Прочее» свёрнуто]
|
||||||
|
C -->|Выбирает валюту, если валют две и больше| E[Пересчёт по позициям валюты, «Прочее» свёрнуто]
|
||||||
|
C -->|Нажимает «Прочее»| F[Остальные элементы раскрыты, топ-5 и кольцо без изменений]
|
||||||
|
F -->|Повторное нажатие| C
|
||||||
|
C -->|Читает предупреждение и сноску| G[«Данные неполные» над разрезом; «Не учтено позиций: N» под списком]
|
||||||
|
C -->|Переключает другую вкладку страницы| H[Разрез и валюта сохраняются]
|
||||||
|
H -->|Возврат на «Концентрация»| C
|
||||||
|
C -->|Уходит со страницы или переключает счёт| I[Состояние сброшено]
|
||||||
|
I -->|Новый заход| B
|
||||||
|
J[Режим «Все счета»] -.->|Вкладки нет| X((выход))
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Открыть вкладку.** Вход: «Концентрация» в `Tabs.List` справа от «Позиции». Выход: разрез «Акции», первая валюта, кольцо с центром, список.
|
||||||
|
2. **Переключить разрез.** Один шаг на `SegmentedControl`. Валюта остаётся, «Прочее» сворачивается.
|
||||||
|
3. **Переключить валюту.** `Select`; пересчитывается текущий разрез, «Прочее» сворачивается. При одной валюте выбора нет, шаг пропускается.
|
||||||
|
4. **Раскрыть «Прочее».** Нажатие на строку (мышь, касание, Enter/Space), повторное нажатие сворачивает.
|
||||||
|
5. **Прочитать предупреждение и сноску.** Оба — текст без действий.
|
||||||
|
6. **Вернуться с другой вкладки страницы.** Разрез и валюта прежние; **новый заход** (уход со страницы, смена счёта, перезагрузка) — снова «Акции» и первая валюта.
|
||||||
|
7. **Пустой разрез.** User остаётся на вкладке и переключает разрез или валюту (если выбор показан).
|
||||||
|
|
||||||
|
## Information Architecture and Screen Structure
|
||||||
|
|
||||||
|
Положение: `Tabs.Tab value="concentration"` между «Позиции» и «Деньги». Вкладка не меняет `defaultValue` (`positions`); панель с `keepMounted={false}`, как у остальных. Требование сохранять разрез и валюту при переключении вкладок означает, что выбор живёт выше панели; способ — в `design.md`.
|
||||||
|
|
||||||
|
Порядок внутри панели:
|
||||||
|
|
||||||
|
1. **Предупреждение «Данные неполные»** — только при неполноте Portfolio, над карточкой (M5, M6). Один блок для всех разрезов: остаётся на месте при переключении разреза.
|
||||||
|
2. **Карточка** (`Paper withBorder radius="lg"`): шапка, затем две колонки на десктопе / одна на мобильном.
|
||||||
|
3. **Шапка.** `SegmentedControl` «Акции / Облигации / Секторы / Эмитенты» (aria-label «Разрез концентрации»). Справа подпись «Валюта» и `Select` — **только если валют две и больше**; иначе шапка содержит один переключатель (M5, M6).
|
||||||
|
4. **Hero-блок** (подложка `blue.0`, градиент в белый, радиус `lg`): кольцо 220 px, толщина 32. **Центр:** вверху «Топ-5 · N %» (muted, только если элементов шесть и больше), под ним сумма разреза (bold). Без метрики — только сумма (M6). Под кольцом подпись «<Разрез> · <валюта>».
|
||||||
|
5. **Ранжированный список** (`<ol>`): номер, маркер, [логотип], название и подпись, справа сумма и доля, полоса доли.
|
||||||
|
6. **Строка «Прочее»** (при семи и более элементах): кнопка-disclosure с «N элементов», суммой, долей и шевроном. Раскрытие показывает остальные элементы вложенным списком (M2).
|
||||||
|
7. **Сноска** «Не учтено позиций: N» (только при N > 0).
|
||||||
|
|
||||||
|
Содержимое строки по разрезам:
|
||||||
|
|
||||||
|
| Разрез | Логотип | Подпись под названием |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Акции | `PositionLogo` 40 px, без ссылки | тикер или идентификатор позиции |
|
||||||
|
| Облигации | `PositionLogo` 40 px, без ссылки | ISIN |
|
||||||
|
| Секторы | нет | нет |
|
||||||
|
| Эмитенты | **нет** (`Строки без переходов`) | нет |
|
||||||
|
|
||||||
|
Логотип в «Секторах» не нужен: элемент объединяет позиции. Строка «Прочее» логотипа не имеет. Ни одна строка не ведёт на другую страницу; `PositionLogo` и название не получают `to`.
|
||||||
|
|
||||||
|
**Кодирование без цвета** (`Доступность диаграммы без цвета`, `Группы «Сектор не указан» и «Эмитент не указан»`):
|
||||||
|
|
||||||
|
| Признак | Кодирование |
|
||||||
|
| --- | --- |
|
||||||
|
| Ранг 1–6 | Цифра в строке и у сегмента (для сегментов ≥ 4 %) + монохромная синяя шкала `blue.9 → blue.0` с обводкой `blue.9` |
|
||||||
|
| «Прочее» | Серая точечная штриховка в маркере, сегменте и полосе, серая обводка; слово «Прочее» |
|
||||||
|
| «Сектор не указан» / «Эмитент не указан» | Диагональная штриховка поверх цвета ранга; название в тексте |
|
||||||
|
| Значение | Всегда число в тексте строки; полоса и кольцо его дублируют |
|
||||||
|
|
||||||
|
Шкала однотонная, потому что `grape`, `orange` и `green` в «Структуре портфеля» означают классы активов (`portfolioCategoryPresentation`). Длина полосы — доля от 100 % суммы разреза, шкала совпадает с кольцом.
|
||||||
|
|
||||||
|
**Центр кольца.** Внутренний диаметр 156 px. «Топ-5 · N %» — 14 px / 600, сумма — 22 px / 700; сумма точная, без округления (`Центр диаграммы`), поэтому при длинной записи размер ступенчато уменьшается до 14 px (22 → 18 → 14), без переноса и обрезки. Обе строки входят в текстовый эквивалент.
|
||||||
|
|
||||||
|
## Interaction Behavior
|
||||||
|
|
||||||
|
| Элемент | Поведение |
|
||||||
|
| --- | --- |
|
||||||
|
| Переключение разреза | Fade + `translateY(4px)` 220 ms `ease-out`, как смена валюты в `PortfolioStructure`; при `prefers-reduced-motion: reduce` анимации нет. Вращения сегментов нет (`isAnimationActive: false`). Фокус остаётся на переключателе. |
|
||||||
|
| Смена валюты | Та же анимация; «Прочее» сворачивается. Если выбранная валюта пропала после обновления Portfolio, выбирается первая (`Независимый выбор валюты вкладки`), без уведомления. Если валюта осталась одна, `Select` исчезает. |
|
||||||
|
| Строка «Прочее» | `<button>` с `aria-expanded` и `aria-controls`; зона — вся строка, высота ≥ 44 px. Шеврон поворачивается на 180°, список раскрывается ≤ 200 ms (при reduce-motion — мгновенно). Фокус остаётся на кнопке. Пять строк и кольцо не меняются. |
|
||||||
|
| Строки топ-5 и раскрытые элементы | Не интерактивны: курсор обычный, переходов нет (`Строки без переходов`). |
|
||||||
|
| Hover/focus | Hover на сегменте подсвечивает строку и показывает tooltip «название · сумма · доля» (вид `PortfolioTooltip`); hover на строке подсвечивает сегмент. Это подсветка, не действие; вся информация доступна в тексте. |
|
||||||
|
| Касание | Tooltip по касанию сегмента — дополнение; список читается без жестов. |
|
||||||
|
| Фокус | Стандартное кольцо Mantine, не скрывается (показан на «Прочее» в M2). |
|
||||||
|
| Предупреждение «Данные неполные» | Статический блок `Alert role="status"`, без кнопок; ручного обновления нет (`portfolio-view`). |
|
||||||
|
|
||||||
|
## UI States
|
||||||
|
|
||||||
|
Данные вкладки выводятся из уже загруженного Portfolio; собственных loading и error у вкладки нет. Состояния — только те, что есть в Specs: `Вкладка в состояниях страницы`, `Пустое состояние разреза`, `Предупреждение о неполных данных`, `Сноска о не учтённых позициях`, `Доступные валюты вкладки`, `Состояния Portfolio на вкладке «Концентрация»`.
|
||||||
|
|
||||||
|
| Экран / область | Loading | Empty | Error | Success | Partial / stale |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Страница счёта (Portfolio) | Skeleton страницы (`AccountPageLoading`); **вкладки «Концентрация» нет** | Штатный пустой Portfolio: **вкладки нет** (`AccountPageEmpty`) | `StateView error` (`AccountPageError`); **вкладки нет** | — | **Stale:** `SourceSyncStatus` над вкладками, как для «Позиций»; разрезы по сохранённому снимку. **Partial:** вкладка есть, над разрезом «Данные неполные» (M5, M6), разрезы по доступным данным |
|
||||||
|
| Вкладка, разрез с данными | n/a | n/a | n/a | Центр, кольцо, список, сноска при N > 0 (M1–M3, M5, M6) | Сноска «Не учтено позиций: N». Позиции без оценки при полном Portfolio предупреждение «Данные неполные» не вызывают |
|
||||||
|
| Разрез без учитываемых элементов (в том числе у счёта только с денежными остатками) | n/a | Название разреза и валюта (если валюта есть) вместо кольца, списка, метрики; `Select` (если показывается) и сноска остаются (M4) | n/a | n/a | Сноска при N > 0 |
|
||||||
|
| Выбор валюты | n/a | Скрыт при одной валюте и при отсутствии валют | n/a | `Select` при двух и более | — |
|
||||||
|
| Метрика «Топ-5 · N %» | n/a | Скрыта при пяти и менее элементах (M6) | n/a | Показана при шести и более (M1, M2, M5) | — |
|
||||||
|
| «Прочее» | n/a | Строки нет при шести и менее элементах (M5) | n/a | Свёрнуто / раскрыто | — |
|
||||||
|
| Группы «не указан» | n/a | n/a | n/a | Обычный элемент с названием и штриховкой (M2, M5) | Не влияют на успешность и свежесть Portfolio |
|
||||||
|
|
||||||
|
Disabled, offline, «нет прав»: не применимо (действий записи нет); недоступный счёт обрабатывает страница, вкладка в этих состояниях не показывается.
|
||||||
|
|
||||||
|
## Validation Feedback
|
||||||
|
|
||||||
|
Пользовательского ввода, кроме выбора из закрытых списков (разрез, валюта), нет; валидации и ошибок ввода нет. Обратная связь на выбор — мгновенная перерисовка (см. Design-level items про озвучивание).
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
Опора на существующую границу `48em`, уже применяемую в `PortfolioStructure.module.css`.
|
||||||
|
|
||||||
|
| Ширина | Раскладка |
|
||||||
|
| --- | --- |
|
||||||
|
| ≥ 48em, макеты M1, M2, M5 (1280) | Шапка в одну строку. Две колонки: hero 296 px + список, `gap` 32 (`xl`). Предупреждение (если есть) на всю ширину над карточкой. |
|
||||||
|
| < 48em, макеты M3, M4, M6 (390) | Одна колонка: переключатель разрезов во всю ширину; при двух и более валютах ниже строка «Валюта + Select» (в M6 её нет); hero во всю ширину, кольцо 220 по центру; ниже список. Строка: логотип 40 (если положен), название и подпись слева, сумма и доля справа, полоса под ними. |
|
||||||
|
| 320 px (минимум) | Переключатель разрезов — в контейнере с `overflow-x: auto` (паттерн `positionFilter`), подписи не сжимаются; страница не скроллится по горизонтали. |
|
||||||
|
| Вкладки страницы на мобильном | Существующая `Tabs.List` (`flex-wrap: nowrap; overflow-x: auto`, класс `tabsList`) прокручивается по горизонтали. В макетах (M3, M4, M6) это показано так: вкладка «Операции» обрезана краем экрана, а не вмещена целиком, и справа 40 px градиентное затухание. Обрезанная вкладка — основной признак прокрутки; затухание — необязательное усиление (см. Design-level items). «Концентрация» — вторая вкладка и на 320 px видна целиком. |
|
||||||
|
| Длинные названия | Название переносится до двух строк; суммы `white-space: nowrap`, `font-variant-numeric: tabular-nums`. Отсутствие названия — fallback из `Названия элементов`. |
|
||||||
|
| Много элементов в «Прочее» | Вложенный список без собственной прокрутки. |
|
||||||
|
| Масштаб текста 200 % | Колонки схлопываются в одну; числа не усекаются; сумма в центре кольца уменьшается ступенчато, но не обрезается. |
|
||||||
|
|
||||||
|
Целевые зоны переключателя, `Select` и «Прочее» ≥ 40–44 px (WCAG 2.2 AA 2.5.8: ≥ 24 px).
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Ориентир: WCAG 2.2 AA. Основа — `Доступность диаграммы без цвета` и `Центр диаграммы`.
|
||||||
|
|
||||||
|
| Элемент | Роль / имя |
|
||||||
|
| --- | --- |
|
||||||
|
| Панель | `Tabs.Panel`, имя от вкладки; внутри `<section aria-labelledby>` с заголовком «Концентрация» (visually hidden или `Title order={2}`) |
|
||||||
|
| Предупреждение | `Alert role="status"` с текстом «Данные неполные», не `alert` (не прерывает) |
|
||||||
|
| Переключатель разрезов | `SegmentedControl` (radiogroup), aria-label «Разрез концентрации» |
|
||||||
|
| Валюта | `Select`, aria-label «Валюта концентрации» (отличается от «Валюта структуры портфеля»); при одной валюте элемента нет в DOM |
|
||||||
|
| Кольцо | `role="img"`, имя «Распределение: <разрез>, <валюта>» + `aria-describedby` на скрытый текст: центр («Топ-5 · 86,5 %, 2 235 310 ₽» либо только сумма) и затем «1. Сбербанк, 612 480 ₽, 27,4 %; … Прочее, …» в порядке списка. Сегменты в порядок фокуса не входят |
|
||||||
|
| Список | `<ol aria-label="<разрез>: топ элементов">`; каждый элемент читается как «название, сумма, доля» |
|
||||||
|
| Полоса, маркер, штриховка | `aria-hidden` (дублируют текст) |
|
||||||
|
| «Прочее» | `<button aria-expanded aria-controls>`, имя «Прочее, N элементов, <сумма>, <доля>» |
|
||||||
|
| Сноска | Обычный текст, не `alert` |
|
||||||
|
|
||||||
|
**Клавиатура.** Порядок фокуса: вкладки страницы → разрез → валюта (если показана) → «Прочее» (если есть) → остальной контент. Строки не фокусируются, потому что на них нет действий. Enter/Space на «Прочее» переключает раскрытие; фокус при раскрытии не уходит. Прокручиваемый ряд вкладок на мобильном остаётся доступен с клавиатуры стрелками (поведение `Tabs`).
|
||||||
|
|
||||||
|
**Не только цвет.** Ранг — цифра + светлота + обводка; «Прочее» — точечная штриховка + слово; «не указан» — диагональная штриховка + слово; предупреждение — текст «Данные неполные».
|
||||||
|
|
||||||
|
**Контраст (расчёт по токенам `@wealthledger/tokens`, светлая схема)**
|
||||||
|
|
||||||
|
| Пара | Коэффициент | Требование |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| text `dark.7` на белом | 15,5 : 1 | 4,5 |
|
||||||
|
| text `dark.7` на `blue.0` hero | 14,3 : 1 | 4,5 |
|
||||||
|
| muted `gray.7` на белом / на `blue.0` hero | 8,2 : 1 / 7,5 : 1 | 4,5 |
|
||||||
|
| Обводка сегмента `blue.9` на белом | 10,4 : 1 | 3 (граница графики) |
|
||||||
|
| Заливка `blue.6` полосы на треке `gray.1` | 4,7 : 1 | 3 |
|
||||||
|
| Серый контур «Прочее» `gray.6` на белом | 3,3 : 1 | 3 |
|
||||||
|
| Светлые заливки рангов 4–6 (`blue.3/1/0`) на белом | 1,8 / 1,2 / 1,1 : 1 | Информации не несут: различимы обводкой `blue.9` и цифрой |
|
||||||
|
| `gray.6` для мелкого текста | 3,3 : 1 | Не использовать; `muted` в теме — `gray.7` |
|
||||||
|
|
||||||
|
**Тёмная тема.** `docs/design/overview.md`: «тёмная тема не заявлена», токены описывают только светлую схему; контраст для тёмной не рассчитывался. Цвета в реализации берутся из токенов / CSS-переменных; литералы в SVG нужны только для самодостаточности.
|
||||||
|
|
||||||
|
**Движение.** `prefers-reduced-motion: reduce` отключает fade, раскрытие и переходы подсветки.
|
||||||
|
|
||||||
|
**Увеличение текста.** До 200 % без потери информации.
|
||||||
|
|
||||||
|
## Design System Reuse
|
||||||
|
|
||||||
|
| Часть | Решение | Обоснование |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Вкладка | **Reuse** `Tabs.Tab` страницы счёта | Консистентная навигация |
|
||||||
|
| Прокрутка вкладок на мобильном | **Reuse** `tabsList` (`overflow-x: auto`) | Уже работает; затухание у правого края в макете — необязательное улучшение |
|
||||||
|
| Переключатель разрезов | **Reuse** `SegmentedControl` + обёртка с `overflow-x`, как у «Акции / Облигации / Прочее» | Тот же паттерн внутри вкладки |
|
||||||
|
| Выбор валюты | **Reuse** `Select` (`allowDeselect={false}`, `w=112`, aria-label) из `PortfolioStructure`; скрывается при ≤ 1 валюте, как там | Единое поведение и вид |
|
||||||
|
| Кольцо | **Reuse** `DonutChart` (size 220, thickness 32, `startAngle 90 / endAngle -270`, без анимации, tooltip по сегменту) | Знакомая форма |
|
||||||
|
| Tooltip | **Reuse** `PortfolioTooltip` (`Paper`, `Text fw=600`) | Тот же вид |
|
||||||
|
| Логотипы | **Reuse** `PositionLogo` (Avatar 40, `radius md`, fallback — первая буква) без `to` | Совпадает с `PositionCard`; переходов по Specs нет |
|
||||||
|
| Предупреждение | **Reuse** `Alert role="status"` (как `investment.partial` страницы) | Тот же вид неполных данных |
|
||||||
|
| Состояния страницы | **Reuse** `StateView`, `SourceSyncStatus`, `LoadingSkeleton` | Наследуются без изменений |
|
||||||
|
| Анимация смены | **Reuse** keyframes `currency-change` | Единый ритм |
|
||||||
|
| Центр кольца из двух строк | **Новый** («Топ-5 · N %» + сумма вместо одной `chartLabel`) | Требует `Центр диаграммы`; реализация — в `design.md` |
|
||||||
|
| Строка рейтинга с полосой | **Новый** (номер, маркер, название, сумма/доля, полоса) | Нет компонента «строка доли»; `EntryCard` и `PositionCard` выражают карточку операции/позиции. Собирается из `Group`, `Text` и полосы |
|
||||||
|
| Шкала рангов | **Новый** (монохромные `blue.9…0`) | Не смешивать с цветами классов активов; только палитра токенов |
|
||||||
|
| Штриховки | **Новый** вспомогательный признак | `Доступность диаграммы без цвета`; название остаётся главным признаком |
|
||||||
|
| Disclosure «Прочее» | **Новый** по структуре (`UnstyledButton` + `Collapse`) | В `@wealthledger/ui` аналога нет; поведение задано Specs |
|
||||||
|
| Видимая подпись «Валюта» рядом с `Select` | **Отклонение** | В `PortfolioStructure` только aria-label; подпись поясняет значение на мобильном, aria-label сохраняется. Можно снять без последствий для Specs |
|
||||||
|
| Подпись «<Разрез> · <валюта>» под кольцом | **Новый** текст | Помогает при одной валюте, когда `Select` скрыт; значений не добавляет |
|
||||||
|
|
||||||
|
Новые токены не вводятся; в `@wealthledger/ui` ничего не добавляется, предметные компоненты остаются в приложении (`docs/design/overview.md`).
|
||||||
|
|
||||||
|
## Materials
|
||||||
|
|
||||||
|
Относительные пути заданы от каталога этого файла. SVG самодостаточны (без scripts, handlers, внешних шрифтов и ресурсов; `font-family: system-ui, sans-serif`), содержат `<title>` и `<desc>` с текстовым эквивалентом, включая центр кольца. Данные синтетические; цвета в SVG — литералы токенов для самодостаточности.
|
||||||
|
|
||||||
|
PNG-превью не создавались: единственный доступный рендерер (`qlmanage`) выдаёт квадратные миниатюры с полями, непригодные как эталон.
|
||||||
|
|
||||||
|
| ID материала | Относительный путь / ссылка | Экран/состояние и назначение | SHA-256 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| M1 | `../visuals/desktop-stocks.svg` | Desktop 1280: «Акции», RUB, две валюты (выбор виден), 8 элементов: центр «Топ-5 · 86,5 %» + сумма, топ-5 с логотипами, «Прочее» свёрнуто, сноска «Не учтено позиций: 2» | `c4ef8f814f6b168d3d96b6b8288ee6c574b4cf361de451a2152b1e51c939dae5` |
|
||||||
|
| M2 | `../visuals/desktop-sectors-expanded.svg` | Desktop 1280: «Секторы», «Прочее» раскрыто и в фокусе, «Сектор не указан» со штриховкой на 5-м месте, без логотипов, центр «Топ-5 · 92,1 %» + сумма | `b59256bbcca8a414af85b487966dbda314ca30bbe20c5a65cf313096b4305106` |
|
||||||
|
| M3 | `../visuals/mobile-stocks.svg` | Mobile 390: «Акции», одна колонка, выбор валюты в отдельной строке, прокручиваемый ряд вкладок (обрезанная «Операции» + затухание) | `d0ed332a78bdf8c9646a3033db63fc45643b4f365b0fabf34f2433b815663dbd` |
|
||||||
|
| M4 | `../visuals/mobile-empty-slice.svg` | Mobile 390: пустое состояние «Облигации» в RUB (валют две, выбор виден), сноска «Не учтено позиций: 1» | `04f8be877948c3a46f0cea64558c5da1a3577a3a4b947f32b7341f83eda0a377` |
|
||||||
|
| M5 | `../visuals/desktop-issuers-warning.svg` | Desktop 1280: «Эмитенты», одна валюта (выбора нет), предупреждение «Данные неполные», шесть элементов без «Прочее», метрика «Топ-5 · 94,2 %» + сумма, без логотипов, «Эмитент не указан» со штриховкой | `ece4fa5499cd5c326569e3a3b3069546dc66964b9972cac65140449206fcb4ae` |
|
||||||
|
| M6 | `../visuals/mobile-issuers-warning.svg` | Mobile 390: «Эмитенты», пять элементов: в центре только сумма (метрики нет), предупреждение «Данные неполные», без выбора валюты и без логотипов | `1380c45d3db93c9b778a77c210f4444a19fe6666dff8b32080312584b78719ae` |
|
||||||
|
|
||||||
|
Что проверено: валидный XML (`xmllint`), отсутствие scripts, `href`, `<image>` и `@font-face`, оценка наложений текста, расчёт контраста по токенам. Макеты не просматривались на отрисованном изображении. Единственное «выходит за экран» по оценке — «Операции» в M3, M4, M6, и это намеренная обрезка `clipPath`. Макеты — иллюстрация компоновки, а не пиксельная спецификация.
|
||||||
|
|
||||||
|
## Закрытые findings для Specs
|
||||||
|
|
||||||
|
Решения владельца ([#48, 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) внесены в Specs; UX-часть приведена в соответствие.
|
||||||
|
|
||||||
|
| ID | Решение | Requirement | Где отражено |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| S1 | Разрез и валюта сохраняются, пока User на странице, включая переключение вкладок; сбрасываются при новом заходе | `Начальное состояние вкладки` | User Flows 6, IA |
|
||||||
|
| S2 | `Select` скрыт при одной валюте и без валют | `Доступные валюты вкладки` | IA 3, UI States, M5, M6 |
|
||||||
|
| S3 | Метрика «доля топ-5» скрыта при пяти и менее элементах | `Суммарная доля топ-5` | IA 4, UI States, M6 |
|
||||||
|
| S4 | Неполный Portfolio: вкладка есть, над разрезом «Данные неполные» | `Предупреждение о неполных данных` | IA 1, UI States, M5, M6 |
|
||||||
|
| S5 | Вкладки нет при загрузке, ошибке и штатном пустом Portfolio | `Вкладка в состояниях страницы` | UI States |
|
||||||
|
| S6 | Неизвестный сектор показывается как получено, без пояснений | `Подписи секторов` | Без UX-обработки; текст читается скринридером как есть |
|
||||||
|
| S7 | «Прочее» показывает число остальных: «2 элемента», «5 элементов» | `Топ-5 и строка «Прочее»` | IA 6, M1, M2 |
|
||||||
|
| S8 (часть) | Центр кольца: «Топ-5 · N %» и сумма разреза | `Центр диаграммы` | IA 4, Accessibility |
|
||||||
|
| S9 | Без логотипов в «Эмитентах» и без переходов из строк | `Строки без переходов` | IA, Interaction Behavior, Design System Reuse |
|
||||||
|
|
||||||
|
Ранее предложенные вопросы Q1 (сумма разреза), Q3 (логотип эмитента), Q4 (переход из строки) закрыты теми же Requirements; Q8 (масштаб полосы от 100 %) принят как дизайн-решение.
|
||||||
|
|
||||||
|
## Design-level items
|
||||||
|
|
||||||
|
Поведения не меняют; остаются на уровне дизайна, подтверждаются в `design.md` / на ревью макетов. Пункты D1–D4 перенесены из прежних вопросов по просьбе владельца, D5–D7 найдены при обновлении.
|
||||||
|
|
||||||
|
| ID | Вопрос | Предложение по умолчанию |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | Тексты пустого состояния. Specs задают содержание (название разреза и валюта, если есть), но не формулировку | Заголовок «<Разрез> в <валюта>: нет позиций» (без валюты, если валют нет); пояснение «Нет позиций с положительной оценкой в этом разрезе и выбранной валюте.» |
|
||||||
|
| D2 | Озвучивание смены разреза и валюты (бывший S8) | Одна `role="status"` (polite) строка: «Разрез «Секторы», RUB: 8 элементов»; при первом показе не объявляется |
|
||||||
|
| D3 | Минимальная видимая дуга для очень малых долей | Минимума нет (доли честные); сегмент опознаётся по списку и обводке |
|
||||||
|
| D4 | Нумерация внутри раскрытого «Прочее» | Продолжение ранга (6, 7, …), как в M2 |
|
||||||
|
| D5 | Сообщения о неполноте: на вкладке «Концентрация» страничное «Период загружен не полностью» не показывается, остаётся только «Данные неполные» на вкладке | Закрыт: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082); реализация — design.md F4 |
|
||||||
|
| D6 | Затухание у правого края мобильных вкладок отсутствует в коде; ряд уже прокручивается и обрезанная вкладка сама показывает прокрутку | Затухание не обязательно; если принимается, оно общее для всех вкладок страницы, а не только для «Концентрации» |
|
||||||
|
| D7 | Реализация двухстрочного центра `DonutChart` (`children` либо наложенный `Text`) и ступенчатое уменьшение суммы | Решает `design.md`; UX требует две строки, точную сумму без обрезки и текстовый эквивалент |
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# Engineering Design
|
||||||
|
|
||||||
|
Design описывает, как backend и frontend реализуют утверждённые Specs и модули analysis для change `account-top-positions`. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](analysis/system-analysis.md); Requirement указываются по названию заголовка. Пути с `backend/` и `frontend/` отсчитываются от workspace; база кода — backend `d37fa2c`, frontend `71126c9`. Design и [ux-design.md](analysis/ux-design.md) образуют один Design package с одним approval на G3. UX здесь не пересказывается: этот документ описывает только инженерную реализацию.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Specs (G2 approved): `specs/portfolio-concentration/spec.md` (28 Requirement), `specs/portfolio-view/spec.md` (PVΔ «Нормализованный состав Portfolio», PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента»).
|
||||||
|
- Analysis (G2 approved): [system-analysis.md](analysis/system-analysis.md) (контракты, семантическая delta `PortfolioPositionDto`, заметка о дублировании Alert), [data-mapping.md](analysis/data-mapping.md) (M1–M3, источники-кандидаты в T-Bank, инварианты I1–I8), [failure-model.md](analysis/failure-model.md) (классификация исходов, F01–F16).
|
||||||
|
- Design package (G3): [ux-design.md](analysis/ux-design.md), Visual Package **v2** (`visuals/*.svg`, SHA-256 — в ux-design «Materials»).
|
||||||
|
- Параллельно с design: [qa-analysis.md](analysis/qa-analysis.md) и `analysis/test-cases/` (oracle для расчётов).
|
||||||
|
- Ограничения: `backend/AGENTS.md` (integrations, data, http-api conventions), `frontend/AGENTS.md` (`.ai/architecture.md`, `.ai/conventions/ui.md`, `.ai/conventions/data.md`), ADR 015, 017, 019 (`docs/adr/`).
|
||||||
|
- `analysis/contracts/` нет. Target-контракт — таблица «Семантическая delta `PortfolioPositionDto`» в system-analysis.
|
||||||
|
|
||||||
|
## Architecture Drivers
|
||||||
|
|
||||||
|
| ID | Driver | Источник |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | Сектор и бренд хранятся как последнее известное значение. Обновление — при первом обходе, начавшемся не ранее чем через 24 ч после последнего **успешного** получения; пока значение не получено — при каждом обходе. Сектор и бренд независимы | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента» |
|
||||||
|
| D2 | Четыре исхода получения: заменить, `null`, сохранить прежнее; отсчёт сбрасывается или не меняется | PVΔ «Результат получения сектора и эмитента», failure-model «Классификация» |
|
||||||
|
| D3 | Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: fallback не возвращается, `400 INVALID_TBANK_TOKEN` | PVΔ «Свежесть и fallback полного Portfolio Snapshot», failure-model F08 |
|
||||||
|
| D4 | Прочие отказы не мешают публикации Snapshot и не меняют `isStale` или `lastSuccessfulAt` | PVΔ «Нормализованный состав Portfolio», PVΔ «Результат получения сектора и эмитента», F02–F12 |
|
||||||
|
| D5 | GET не обращается к T-Bank | PVΔ «Свежесть и fallback полного Portfolio Snapshot»; ADR 019 |
|
||||||
|
| D6 | Квоты T-Bank: каждый RPC проходит через общий RateLimiter (`TBANK_RATE_LIMIT_PER_SECOND`, по умолчанию 5/с) и bounded queue на 50 элементов | `backend/src/integrations/tbank/tbank.client.ts:147-190,52`; integrations convention; Q-F3 |
|
||||||
|
| D7 | Аддитивный DTO: `sector`, `issuer{id,name}` присутствуют всегда и nullable; в старых Snapshot — `null` | system-analysis «Семантическая delta», «Совместимость» |
|
||||||
|
| D8 | Точные суммы и доли (в сумме ровно 100 %), округление до десятых с серединой вверх, «< 0,1 %» | PC «Расчёт долей», PC «Показ долей», PC «Суммарная доля топ-5» |
|
||||||
|
| D9 | Разрез и валюта сохраняются при переключении вкладок страницы и сбрасываются при новом заходе или смене счёта | PC «Начальное состояние вкладки» |
|
||||||
|
| D10 | На вкладке «Концентрация» одно сообщение о неполноте; страница его не дублирует | PC «Предупреждение о неполных данных»; system-analysis «Заметка для design» |
|
||||||
|
| D11 | Информация диаграммы передаётся не только цветом; есть текстовый эквивалент; в центре две строки | PC «Диаграмма распределения разреза», PC «Центр диаграммы», PC «Доступность диаграммы без цвета»; ux-design «Accessibility», D7 |
|
||||||
|
| D12 | FSD-границы, вычисление в VM без effect, generated types берутся только из предоставленного snapshot | `frontend/.ai/architecture.md`, `ui.md`, `data.md` |
|
||||||
|
|
||||||
|
## Technical Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- получать сектор и бренд в существующем часовом шаге Portfolio без нового фонового процесса;
|
||||||
|
- хранить их с возрастом каждого поля отдельно;
|
||||||
|
- добавить два DTO-поля;
|
||||||
|
- считать разрезы на клиенте чистой функцией с точной десятичной арифметикой.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- менять общую metadata инструментов (`InstrumentMetadataService`) и её семантику свежести;
|
||||||
|
- переписывать сохранённые Snapshot;
|
||||||
|
- конвертировать валюты;
|
||||||
|
- добавлять новые библиотеки, очереди, таймеры или процессы;
|
||||||
|
- исправлять `formatShare` в карточке позиции (см. Risks);
|
||||||
|
- менять каталог акций и облигаций.
|
||||||
|
|
||||||
|
## Implementation Impact Map
|
||||||
|
|
||||||
|
| Область | Затронута | Результат или причина N/A |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| main | нет | Root содержит только workspace-навигацию; кода и контрактов там нет |
|
||||||
|
| backend | да | Prisma-модель и аддитивная миграция; 3 метода `TbankClient` и opt-in обработка NOT_FOUND; `PositionClassificationService`; шаг в `PortfolioService.queueSnapshot` до publish; параметр начала обхода из `TbankHourlyScheduler`; DTO `sector`/`issuer`; нормализация старых Snapshot в `saved()` |
|
||||||
|
| frontend | да | Новый openapi snapshot и `schema.d.ts`; `pages/account/model/concentration.ts`; state вкладки, разреза и валюты в `AccountPage.vm.ts`; управляемые `Tabs` и подавление Alert в `AccountPageLoaded`; view `Concentration/`; ключ `stock.sectors.government` в `ru.ts` |
|
||||||
|
| specs | да | Этот `design.md`. Specs не меняются; findings — в разделе Risks. Contract Parity на G4 сравнивает generated OpenAPI с delta из system-analysis |
|
||||||
|
| docs | да | Объём определяет docs-impact. Инженерные затронутые места: исключение из «справочник онлайн» в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019, домен Portfolio (поля позиции, вкладка), страница интеграции T-Bank (ShareBy/BondBy/EtfBy/GetAssetBy), глоссарий |
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**B1. Отдельная таблица классификации, а не общий metadata-кэш** (D1, D2, D4)
|
||||||
|
|
||||||
|
- **Контекст.** Общий кэш хранит один JSON `TbankInstrument.metadata` из GetInstrumentBy. Он считает `fresh = complete` без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:295-323`). `Instrument` не содержит `sector`, а его `brand` — это `BrandData`, а не `Brand{uid,name}` (data-mapping «Что уже есть в коде»). Запрос metadata запускается асинхронно после publish и при каждой ошибке перезаписывает или отбрасывает весь payload (`instrument-metadata.service.ts:111-222`).
|
||||||
|
- **Выбрано.** Новая глобальная (не персональная) таблица `TbankInstrumentClassification`. Состав — в разделе Data / Storage. Возраст сектора и бренда хранится отдельно: `sectorFetchedAt`, `brandFetchedAt`.
|
||||||
|
- **Отвергнуто:** расширение metadata JSON и `selectMetadata`; переиспользование `TbankInstrumentSource`; хранение в payload Snapshot. Причины — в Solution Assessment.
|
||||||
|
|
||||||
|
**B2. Получение — синхронный шаг attempt Portfolio до publish** (D3, D4, D5)
|
||||||
|
|
||||||
|
Шаг встраивается в `queueSnapshot` (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:262-283`):
|
||||||
|
|
||||||
|
1. GetPortfolio и GetPositions.
|
||||||
|
2. `normalize`.
|
||||||
|
3. **`classification.refresh(token, positions, walkStartedAt)`** — новый шаг.
|
||||||
|
4. `mapPortfolio` записывает `sector`/`issuer` из возвращённой Map в Snapshot.
|
||||||
|
5. `repository.publish`.
|
||||||
|
|
||||||
|
Шаг стоит **до** publish, потому что `publish` переводит attempt в `complete` (`portfolio.repository.ts:121-131`), а `rejectMembershipToken` меняет только `running` attempt (`tbank-access.service.ts:233-251`). После publish отказ token не пометил бы подключение недействительным. До publish ошибка `InvalidToken` попадает в существующий `catch` (`portfolio.service.ts:285-291`): Snapshot не публикуется, подключение получает `invalidToken: true`, GET отвечает `400 INVALID_TBANK_TOKEN` через `TbankAccessService.local` (`tbank-access.service.ts:270-272`). Это ровно D3, новых путей ошибок нет.
|
||||||
|
|
||||||
|
Все остальные ошибки шага, включая ошибку записи в БД, ловит `refresh`. Он логирует событие `portfolio.classification.failed` только с `code` (без userId, токена и позиций) и возвращает ранее сохранённые значения. Publish продолжается (D4).
|
||||||
|
|
||||||
|
Отвергнуто: асинхронный шаг после publish по образцу metadata. Он требует отдельного durable work на каждый инструмент и отдельного fence для отказа token, а новых сведений не добавляет.
|
||||||
|
|
||||||
|
**B3. Начало обхода передаётся явно** (D1)
|
||||||
|
|
||||||
|
`TbankHourlyScheduler.walk` (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:134`) фиксирует `walkStartedAt = new Date()` и передаёт его через `dispatchPortfolio` в `portfolio.request(userId, revision, accountId, true, walkStartedAt)` (`:280`). Сейчас других вызывающих `request` нет.
|
||||||
|
|
||||||
|
Правило: поле нужно получать, если `fetchedAt == null` или `walkStartedAt − fetchedAt ≥ 24 ч`. `fetchedAt` — момент получения ответа, как в сценариях PVΔ «Обновление сектора и эмитента». Фактический период обновления ≈ 25 ч принят владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)). Отвергнуто: брать время из durable-строки `TbankSyncWork`, потому что строка переиспользуется по `scopeKey` и не хранит время старта.
|
||||||
|
|
||||||
|
**B4. Вызовы T-Bank** (D2, D6)
|
||||||
|
|
||||||
|
- Новые методы `TbankClient`:
|
||||||
|
- `getEtf` (`etfBy`, sparse service, как `getBond`);
|
||||||
|
- `getAssetBrand(token, assetUid)` → `{assetUid, brand{uid,name}} | null` (`getAssetBy`);
|
||||||
|
- необязательный параметр `notFound: true` в `getShare`/`getBond`/`getEtf`.
|
||||||
|
|
||||||
|
Существующие `getShare`/`getBond`/`getAssetDescription` остаются для каталога без изменений.
|
||||||
|
- **NOT_FOUND.** Сейчас NOT_FOUND превращается в `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальные методы получают Unavailable (`tbank.client.ts:577-583`). Приватный `call()` получает opt-in флаг, и условие становится `opt-in || shareBy || metadataCall`. Opt-in включают только новые вызовы классификации. Глобальное расширение списка методов отвергнуто: для `getAssetBy` оно превратило бы отсутствие описания в карточке компании в 404 вместо 503, а для `bondBy` поменяло бы ответ каталога облигаций. Это observable-изменения вне scope.
|
||||||
|
- **Порядок для позиции STOCK/BOND/FUND:** `ShareBy`/`BondBy`/`EtfBy` по UID; при NOT_FOUND по UID и наличии FIGI — один повтор по FIGI (тот же порядок, что в PVΔ «Нормализованный состав Portfolio» и metadata). Это design-решение в рамках формулировки data-mapping «по instrument UID, затем по FIGI»: формулировка не уточняет, нужен ли переход на FIGI после NOT_FOUND по UID, и design его выбирает. Specs и data-mapping не меняются. Ответ даёт `sector` и `asset_uid`. Затем `GetAssetBy(asset_uid)`, если бренд нужно получать. Если инструмент в этом обходе не получен, используется сохранённый `assetUid`.
|
||||||
|
- **Дедупликация.** Singleton-сервис держит Map in-flight promises: ключ инструмента для `*By` и `asset_uid` для `GetAssetBy`. Повтор внутри одного шага и параллельные attempt разных счетов с одним инструментом или активом делят один RPC. После завершения promise удаляется: это не кэш.
|
||||||
|
- **Квоты.** Цикл последовательный (`await` на каждый инструмент), все RPC проходят через существующий `call()` с RateLimiter, retry и deadline. Вложенного retry нет. Переполнение очереди даёт Unavailable, то есть исход «иная ошибка». Ожидаемый объём — 2 RPC на инструмент раз в сутки плюс повторы при неуспехе; до 200 инструментов это около 80 с при 5/с, в пределах `WORK_WAIT_BOUND_MS` 15 мин (`tbank-hourly.scheduler.ts:30`).
|
||||||
|
|
||||||
|
**B5. Отображение исходов** (D2; failure-model «Классификация»)
|
||||||
|
|
||||||
|
| Наблюдение | Действие с полем | `*FetchedAt` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Непустое значение после `trim`; для бренда — непустая пара `uid`/`name` | Записать | `now` |
|
||||||
|
| `NotFoundException`, пустое поле, нет `asset_uid`, нет актива или бренда, неполная пара | `null` | `now` |
|
||||||
|
| `uid`/`figi` ответа ≠ запрошенному; `asset.uid` ≠ `asset_uid` | Не менять | Не менять |
|
||||||
|
| Unavailable, queue full, ошибка декодирования, ошибка БД | Не менять | Не менять |
|
||||||
|
| `InvalidToken` | Пробросить (B2) | — |
|
||||||
|
|
||||||
|
Типы `OTHER`, `CURRENCY` и прочие не запрашиваются: `sector`/`issuer` = `null` (PVΔ «Нормализованный состав Portfolio»). Для FUND `issuer` заполняется, и разрез «Эмитенты» его отбрасывает на клиенте (PC «Состав разрезов»).
|
||||||
|
|
||||||
|
**B6. DTO и OpenAPI** (D7)
|
||||||
|
|
||||||
|
- `PortfolioPositionDto` (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`) получает поля:
|
||||||
|
- `@ApiProperty({ type: String, nullable: true }) sector`;
|
||||||
|
- `@ApiProperty({ type: PortfolioIssuerDto, nullable: true }) issuer`, где у `PortfolioIssuerDto` обязательные `id` и `name`.
|
||||||
|
|
||||||
|
Используется тот же шаблон nullable-объекта, что у `currentValue: MoneyDto | null`. Это OpenAPI 3.0, синтаксис 3.1 не вводится.
|
||||||
|
- Backend: `npm run openapi:generate` записывает корневой `openapi.json` (не в Git). Frontend по правилу `frontend/.ai/conventions/data.md` «Типы и contract» получает artifact явно: он кладётся в `frontend/src/shared/api/spec/openapi.json` в frontend PR, затем `npm run openapi:generate` и `npm run openapi:check`. Frontend не читает backend checkout.
|
||||||
|
|
||||||
|
**B7. Старые Snapshot** (D7)
|
||||||
|
|
||||||
|
`PortfolioService.saved()` при чтении payload (`portfolio.service.ts:90`) нормализует каждую позицию: `sector: p.sector ?? null`, `issuer: p.issuer ?? null`. Сохранённые payload не переписываются. Следующий успешный часовой обход публикует Snapshot с полями. Data-миграции нет.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**F1. Чистая функция разрезов в `pages/account/model/concentration.ts`** (D8, D12)
|
||||||
|
|
||||||
|
- **Слой.** Вкладка нужна только странице счёта, поэтому `pages/account`, а не widget или entity (`frontend/.ai/architecture.md:32`).
|
||||||
|
- **Сигнатура.** `toConcentration(portfolio, slice, currency)` → `{ currencies, items, other, total, top5Share, unaccounted, empty }`.
|
||||||
|
- **Математика.** Используются только `decimalFromString`/`sumDecimals`/`decimalToString`/`formatDecimal` из `@/shared/lib` и константы масштаба по образцу `positions.ts` (`PERCENT_SCALE`, `formatPositionReturnPercent` с округлением середины вверх на bigint, `frontend/src/pages/account/model/positions.ts:40-69`). Доля считается как bigint-частное с запасом масштаба; показ — округление до десятых с серединой вверх; положительная доля < 0,1 даёт «< 0,1 %». Доля «Прочее» и «Топ-5» — частное от точной суммы, поэтому сумма точных долей = 100 % по построению.
|
||||||
|
- `formatShare` из `entities/portfolio/model/display.ts` не используется: он считает через `Number`.
|
||||||
|
- Порядок равных сумм (PC «Порядок элементов с равной суммой») и выбор имени эмитента (PC «Название эмитента при разных названиях бренда») реализуются внутри модели. Тесты — по oracle из `analysis/test-cases/`.
|
||||||
|
|
||||||
|
**F2. Композиция** (PC «Вкладка «Концентрация» страницы счёта», PC «Вкладка в состояниях страницы»)
|
||||||
|
|
||||||
|
- В `AccountPageLoaded` (`frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:108-112`) добавляется `Tabs.Tab value="concentration"` сразу после «Позиции», только при `accountId !== "all"`. Состояние `loaded` уже исключает загрузку, ошибку и пустой Portfolio (`AccountPage.vm.ts:110-121`), поэтому PC «Вкладка в состояниях страницы» выполняется без новой логики.
|
||||||
|
- Панель — `components/Concentration/Concentration.view.tsx` (+ `.module.css`). View получает готовую модель и именованные действия; data hooks внутри view нет (`ui.md` «Container, VM и view»).
|
||||||
|
|
||||||
|
**F3. State в page VM** (D9, D10)
|
||||||
|
|
||||||
|
- `useAccountPageViewModel` владеет `{ accountId, tab, slice, currency }` в одном `useState`. Если сохранённый `accountId` ≠ текущему, VM при render выдаёт значения по умолчанию (`positions`, `stocks`, первая валюта) без effect. Так смена счёта считается новым заходом. Повторное открытие маршрута монтирует страницу заново.
|
||||||
|
- `Tabs` становятся управляемыми (`value`/`onChange`), `keepMounted={false}` сохраняется: state живёт выше панели.
|
||||||
|
- Если выбранной валюты нет в новом `currencies` (данные обновились), модель берёт первую валюту.
|
||||||
|
- `toConcentration` вызывается в VM только при `tab === "concentration"`. Мемоизация не нужна: функция линейна по числу позиций.
|
||||||
|
|
||||||
|
**F4. Подавление страничного Alert** (D10)
|
||||||
|
|
||||||
|
В `AccountPageLoaded.view.tsx:77-79`: `props.partial ? (tab !== "concentration" && <Alert …investment.partial/>) : <CurrencyCards/>`. На вкладке показывается собственный `Alert role="status"` «Данные неполные» (PC «Предупреждение о неполных данных»). Остальные вкладки не меняются. Карточки валют при неполноте не показываются и на этой вкладке, как в AS-IS. Источник: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
|
||||||
|
|
||||||
|
**F5. DonutChart без прямого recharts** (D11; ux-design D7)
|
||||||
|
|
||||||
|
Проверено на `@mantine/charts` 9.7.3 (`frontend/package.json:41`, `node_modules/@mantine/charts/lib/DonutChart/DonutChart.d.ts:28-64`):
|
||||||
|
|
||||||
|
- `chartLabel` выводит только одну строку `<text>`. Поэтому центр — HTML-оверлей `Text` в `position: relative` контейнере поверх `DonutChart`: две строки, точная сумма без обрезки, ступенчатое уменьшение шрифта через CSS. Оверлей помечен `aria-hidden`, текстовый эквивалент даёт `aria-describedby`.
|
||||||
|
- `cellProps(series)` пробрасывает SVG-атрибуты в каждую ячейку (`node_modules/@mantine/charts/esm/DonutChart/DonutChart.mjs:51,72-78`: `cellProps` передаётся в построение `pieData`, которое уходит в `<Pie data>`). Отсюда задаются обводка и `fill="url(#…)"` для штриховки «Прочее» и «не указан». Узоры (`<pattern>`) лежат в отдельном скрытом `<svg width=0 height=0 aria-hidden>` в той же панели: ссылки `url(#id)` действуют в пределах документа, id берутся из `useId`.
|
||||||
|
- Цифры ранга на сегментах ≥ 4 % рисует `pieProps.label` (render-функция). `pieProps` разворачивается после встроенного `label`, поэтому он его переопределяет.
|
||||||
|
- `role="img"`, `aria-label`, `aria-describedby` передаются через `...others` на root `Box`.
|
||||||
|
- `name` в data — уникальный ключ строки; тип ячейки находится по ключу в замыкании `cellProps`.
|
||||||
|
|
||||||
|
Прямой `recharts` (уже зависимость 3.10.1) не нужен. Отвергнуто: собственный SVG-donut — лишний код без driver.
|
||||||
|
|
||||||
|
**F6. Подписи секторов** (PC «Подписи секторов», PC «Русские подписи известных секторов»)
|
||||||
|
|
||||||
|
Используется шаблон `t(\`stock.sectors.${sector}\`, { defaultValue: sector })` (`frontend/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx:64`) и словарь `stock.sectors` (`frontend/src/shared/i18n/locales/ru.ts:759-770`). Добавляется недостающий ключ `government: "Государственный сектор"` в этот общий словарь. По решению владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) подпись показывается и в каталогах — везде, где выводится сектор через этот словарь (страница акции). Список вариантов фильтра скринера (`frontend/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx:46-62`) не меняется: требования к фильтру нет. Неизвестный код показывается как есть через `defaultValue`. **Это observable-изменение вне Specs:** подпись на странице акции меняется с сырого `government` на «Государственный сектор». Оно одобрено владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)) и фиксируется в Gate Log G3; Specs не дополняются. Проверка — task 2.7. Новые строки вкладки добавляются в `portfolio.concentration.*`.
|
||||||
|
|
||||||
|
## Components and Responsibilities
|
||||||
|
|
||||||
|
| Компонент | Ответственность | Зависит от |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `TbankClient` (+`getEtf`, `getAssetBrand`, opt-in NOT_FOUND) | Типизированные RPC через общую очередь | gRPC, RateLimiter |
|
||||||
|
| `PositionClassificationService` (portfolio/application) | Решает, нужно ли получать данные (B3); выполняет RPC и дедупликацию (B4); отображает исходы (B5); пробрасывает только `InvalidToken`; возвращает Map `instrumentKey → {sector, issuer}` | `TbankClient`, `PortfolioRepository` |
|
||||||
|
| `PortfolioRepository` (+`readClassifications`, `saveClassification`) | Чтение и upsert строк классификации | Prisma |
|
||||||
|
| `PortfolioService` | Встраивает шаг до publish; нормализует старые Snapshot | Сервис выше |
|
||||||
|
| `tbank-portfolio.mapper` | Записывает `sector`/`issuer` в позицию по ключу `metadataKey(position)` | — |
|
||||||
|
| `TbankHourlyScheduler` | Передаёт `walkStartedAt` | — |
|
||||||
|
| `pages/account/model/concentration.ts` | Разрезы, суммы, доли, «Прочее», N не учтённых, валюты | `@/shared/lib` |
|
||||||
|
| `AccountPage.vm.ts` | State вкладки, разреза и валюты; вызов модели | модель |
|
||||||
|
| `AccountPageLoaded` / `Concentration.view` | Композиция, Alert, диаграмма, список | Mantine, `@mantine/charts` |
|
||||||
|
|
||||||
|
Направление зависимостей не меняется: integrations ← modules/portfolio ← tbank-sync; `pages → entities → shared`.
|
||||||
|
|
||||||
|
## Data / Storage Changes
|
||||||
|
|
||||||
|
Новая Prisma-модель (SQLite, `backend/prisma/schema.prisma`) в стиле `TbankInstrumentLookup`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// Public per-instrument classification; no holdings or credentials.
|
||||||
|
model TbankInstrumentClassification {
|
||||||
|
provider String
|
||||||
|
instrumentKey String // metadataKey(position): JSON [uid|null, figi|null]
|
||||||
|
sector String?
|
||||||
|
sectorFetchedAt DateTime?
|
||||||
|
assetUid String?
|
||||||
|
brandUid String?
|
||||||
|
brandName String?
|
||||||
|
brandFetchedAt DateTime?
|
||||||
|
@@id([provider, instrumentKey])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ключ — существующий `metadataKey` (`instrument-metadata.repository.ts:33-38`), без FK на `TbankInstrument`: строка инструмента может ещё не существовать (B1).
|
||||||
|
- Данные публичные и не принадлежат пользователю или подключению, как и `TbankInstrument`. Fence по connection revision не нужен. Публикация Snapshot по-прежнему ограждена (`portfolio.repository.ts:121-131`).
|
||||||
|
- Миграция `…_add_instrument_classification` (`prisma migrate dev --create-only`) делает только `CREATE TABLE`. Прежние таблицы и payload не меняются.
|
||||||
|
- Snapshot (`TbankPortfolio.payload`) получает два поля у позиций. Схема JSON неформальная, поэтому миграция не нужна.
|
||||||
|
|
||||||
|
## Contract Implementation
|
||||||
|
|
||||||
|
| Контракт | Producer → Consumer | Как производится / потребляется | Сверка с target |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio`, `positions[].sector`/`issuer` | Backend → frontend | Nest DTO (B6) → `npm run openapi:generate` → artifact передаётся явно → `frontend/src/shared/api/spec/openapi.json` → `npm run openapi:generate` → generated `paths` | Contract Parity на G4: поля, nullable, required и `issuer.{id,name}` сравниваются с таблицей «Семантическая delta» system-analysis; `npm run test:openapi-contract` подтверждает, что генерация совпадает с runtime |
|
||||||
|
| T-Bank `ShareBy`/`BondBy`/`EtfBy`/`GetAssetBy` | T-Bank → backend | Через существующий proto loader; vendor proto не меняется (RPC уже есть: `instruments.proto:24,42,65,92`) | Unit-тесты client на маппинг полей и NOT_FOUND |
|
||||||
|
|
||||||
|
Ошибки HTTP не меняются: `400 INVALID_TBANK_TOKEN` выдаёт существующий путь.
|
||||||
|
|
||||||
|
## Cross-cutting Implementation
|
||||||
|
|
||||||
|
- **Security.** Токен расшифровывается только в attempt (`tokenForAttempt`) и в логи не попадает. Raw gRPC наружу не уходит. Новых прав и секретов нет (security-analysis не выбран, process-record).
|
||||||
|
- **Observability.** Используется существующий `tbank.rpc.failed` на каждый RPC и одно событие `portfolio.classification.failed { code }` на неуспешный шаг. Новых метрик нет (observability не выбран).
|
||||||
|
- **Concurrency.** In-flight Map в singleton (B4). Upsert одной строки из двух attempt идемпотентен, побеждает последняя запись с тем же содержимым. Один процесс backend — по ADR 015 и scheduler (`ponytail` в `tbank-hourly.scheduler.ts:42-43`).
|
||||||
|
- **Freshness.** Классификация не входит в `meta.dependencies` и не меняет `isStale` или `lastSuccessfulAt` (D4). `dataRevision` Snapshot и так растёт при каждой публикации.
|
||||||
|
|
||||||
|
## UI Implementation
|
||||||
|
|
||||||
|
С ux-design описывается только инженерная часть.
|
||||||
|
|
||||||
|
| UI-состояние | Как получается |
|
||||||
|
| --- | --- |
|
||||||
|
| Вкладки нет | `accountId === "all"` либо VM-состояние не `loaded` |
|
||||||
|
| Разрез / валюта / раскрытие «Прочее» | `slice`, `currency` — в VM (F3); раскрытие — локальный `useState` view: сброс при уходе с вкладки допустим, потому что PC «Раскрытие строки «Прочее»» не требует его сохранять |
|
||||||
|
| Пустой разрез | `model.empty`; выбор валюты и сноска остаются (PC «Пустое состояние разреза») |
|
||||||
|
| «Данные неполные» | `partial` из VM (`AccountPage.vm.ts:124`) → Alert вкладки; страничный подавлен (F4) |
|
||||||
|
| Выбор валюты скрыт | `currencies.length <= 1` |
|
||||||
|
| Сноска N | `model.unaccounted > 0` |
|
||||||
|
| Центр и текстовый эквивалент | `model.total`, `model.top5Share` → оверлей и скрытый текст (F5) |
|
||||||
|
|
||||||
|
Visual Package: используется v2 из ux-design (локальные `visuals/*.svg`, хеши — в ux-design «Materials»). В design новых визуальных материалов нет: реализация не добавляет экранов и состояний сверх v2.
|
||||||
|
|
||||||
|
## Developer Verification Strategy
|
||||||
|
|
||||||
|
Проверки задают implementation owners. QA-матрица — в qa-analysis.
|
||||||
|
|
||||||
|
**backend** (команды из `backend/AGENTS.md` и conventions):
|
||||||
|
- `npx prisma validate`, `npx prisma generate`, `npm run typecheck:app`;
|
||||||
|
- проверка миграции на disposable копии БД с существующими Snapshot: старые payload читаются, GET отдаёт `sector: null` и `issuer: null`;
|
||||||
|
- unit-тесты `PositionClassificationService`: четыре исхода по каждому полю отдельно; границы 23:59 и 24:00 от `walkStartedAt`; «не получено → каждый обход»; дедупликация по `asset_uid`; использование сохранённого `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет;
|
||||||
|
- `portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован и вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован с прежними значениями;
|
||||||
|
- `tbank.client.spec.ts`: opt-in NOT_FOUND для `bondBy`/`etfBy`/`getAssetBy`; прежние методы не изменились;
|
||||||
|
- `npm run test:openapi-contract`, `npm run openapi:generate`, `npm run test:e2e` (portfolio HTTP: shape и `400 INVALID_TBANK_TOKEN`);
|
||||||
|
- финально `npm run verify`.
|
||||||
|
|
||||||
|
**frontend** (команды из `frontend/AGENTS.md`):
|
||||||
|
- предоставленный `openapi.json` → `npm run openapi:generate`, `npm run openapi:check`;
|
||||||
|
- vitest для `concentration.ts` по oracle из `analysis/test-cases/`;
|
||||||
|
- `AccountPage.vm.test.tsx`: сохранение разреза и валюты между вкладками, сброс при смене счёта;
|
||||||
|
- `AccountPage.test.tsx`: вкладки нет для `all`; Alert подавлен только на «Концентрации»;
|
||||||
|
- browser-тест раскладки 320 px по образцу `position-layout.browser.test.tsx`;
|
||||||
|
- финально `npm run verify`.
|
||||||
|
|
||||||
|
**specs:** `openspec validate account-top-positions --strict`, `git diff --check`.
|
||||||
|
|
||||||
|
**docs:** `npm run verify` (`docs/CONTRIBUTING.md`).
|
||||||
|
|
||||||
|
## Migration / Rollout / Rollback
|
||||||
|
|
||||||
|
**Порядок выката:**
|
||||||
|
1. Backup БД и ключа шифрования по ADR 015.
|
||||||
|
2. Backend: миграция `CREATE TABLE` и код. Новые поля сразу в DTO (`null` до первого обхода).
|
||||||
|
3. Публикация artifact OpenAPI.
|
||||||
|
4. Frontend PR с новым snapshot и вкладкой.
|
||||||
|
|
||||||
|
Совместимость: старый frontend игнорирует новые поля, поэтому изменение аддитивно. Первый обход после выката запрашивает данные всех позиций: разовый всплеск RPC, B4.
|
||||||
|
|
||||||
|
**Откат:**
|
||||||
|
- **Frontend.** Revert PR; backend продолжает отдавать поля.
|
||||||
|
- **Backend.** Откат на предыдущий bundle (ADR 016). Лишняя таблица не читается.
|
||||||
|
- Фактический запуск: production image стартует `node dist/src/main.js` (`backend/Dockerfile`, `CMD`); приложение миграции не применяет и `_prisma_migrations` не проверяет (`backend/src/prisma/prisma.service.ts`). Миграции применяет отдельный compose-сервис `migration` (`prisma migrate deploy`, `deploy/compose.yaml:32-37`), от которого зависит `backend` (`depends_on … service_completed_successfully`, `:43-45`). Штатный `deploy.sh` запускает его на каждом обновлении (`deploy/deploy.sh:183-186`); ручной image rollback по `deploy/README.md` «Ручной откат и восстановление» делает `up --no-deps backend` и миграцию намеренно не запускает (то же — `docs/architecture/deployment.md` «Откат image»).
|
||||||
|
- Следствие: при ручном откате старый backend стартует на схеме с лишней таблицей, запись о новой миграции в `_prisma_migrations` остаётся. Если вместо ручного отката запустить `deploy.sh` со старым тегом, старый `migrate deploy` увидит применённую миграцию, которой нет в его каталоге. По документации Prisma `migrate deploy` применяет только недостающие локальные миграции и не сверяет историю, а `migrate status` сообщает о ней как о «applied but missing locally»; это не подтверждено на версии проекта и проверяется в task 3.4. Snapshot с полями `sector`/`issuer` читается старым кодом без ошибок: поля лишние, ответ проходит через сериализацию DTO. На G4 это нужно проверить: whitelist сериализации в backend не найден, поэтому старый код может отдавать лишние поля как есть, и это безвредно.
|
||||||
|
- **БД.** `DROP TABLE "TbankInstrumentClassification"` — по желанию, данные восстанавливаются следующим обходом. Restore из backup не нужен.
|
||||||
|
|
||||||
|
Необратимых шагов нет.
|
||||||
|
|
||||||
|
## Solution Assessment
|
||||||
|
|
||||||
|
Обязателен: новое хранение и кеширование данных внешней интеграции.
|
||||||
|
|
||||||
|
| Критерий | A. Новая таблица + шаг до publish (**выбрано**) | B. Расширить `InstrumentMetadataService` / `TbankInstrument.metadata` | C. Хранить в payload Snapshot с переносом из прежнего |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| D1 возраст по полю | Явные столбцы | Поля возраста внутри JSON; меняется `fresh = complete`, а через `metadataDependencies` и `meta` GET (`portfolio.service.ts:122-138`) | Перенос между Snapshot; при новом подключении (новая revision) значения теряются |
|
||||||
|
| D2 четыре исхода | Простой upsert по полю | Drain перезаписывает весь payload и бросает `Incomplete metadata` / `mismatch` (`instrument-metadata.service.ts:141-186`): исходы смешиваются с логикой name/ticker | Реализуемо, но дублируется на каждый счёт |
|
||||||
|
| D3 отказ token | Существующий catch attempt | Работает через собственный attempt metadata, но после publish | Как A |
|
||||||
|
| Связность / риск | Новый сервис около 100 строк, таблица | Высокий: конкурентная очередь producers/observers, общая для GET и каталога | Средний: логика переноса в mapper |
|
||||||
|
| Обратимость | `DROP TABLE` | Трудно отделить от общего кэша | Payload остаётся в истории |
|
||||||
|
| Новая технология | Нет | Нет | Нет |
|
||||||
|
|
||||||
|
Также отвергнуто: `TbankInstrumentSource`. FK на `TbankInstrument` требует сначала создать строку инструмента, а она служит payload общей metadata и каталога: upsert перезаписал бы `metadata` для GET.
|
||||||
|
|
||||||
|
Условие пересмотра A: если появится второй потребитель сектора или бренда (например, каталог), таблицу стоит сделать общим справочником с собственным владельцем, оформив это через ADR.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Риск / компромисс | Влияние | Мера |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Квоты T-Bank при первом обходе и при постоянных отказах (Q-F3, qa-analysis Gaps) | Шаг Portfolio длиннее; queue full даёт исход «иная ошибка» | Последовательный цикл, дедупликация; ceiling — `WORK_WAIT_BOUND_MS`. Квоты и полнота данных (Q-D1, Q-D2, Q-F3) проверяются на реальном токене в QA до G4 — решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) |
|
||||||
|
| `fetchedAt` = момент ответа, поэтому фактический период ≈ 25 ч (обход раз в час, ответ позже старта) | Обновление на час позже «суток» | Принято владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)); соответствует сценариям PVΔ «Обновление сектора и эмитента» буквально |
|
||||||
|
| Шаг до publish удлиняет время до нового Snapshot | Финансовые данные публикуются на секунды–минуты позже | Только когда данные нужно получать; иначе одно чтение из БД |
|
||||||
|
| Отказ token в классификации блокирует Snapshot | Намеренно, D3 | — |
|
||||||
|
| Карточка позиции считает долю через `Number` (`formatShare`), вкладка — точно | В граничных случаях (x,x5) показ может расходиться на 0,1 п.п. | **Принятый компромисс:** карточка не меняется (вне scope), вкладка следует точному расчёту PC «Расчёт долей»/PC «Показ долей»; Specs не меняются |
|
||||||
|
| Ключ `metadataKey` различает «UID» и «UID+FIGI» | Возможны дубли строк одного инструмента | Безвредно: дополнительный RPC раз в сутки |
|
||||||
|
| `government` появится и в каталогах | Подпись вместо сырого кода | Решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); корректно по таблице PC «Русские подписи известных секторов» |
|
||||||
|
|
||||||
|
**Решения владельца ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)):** период ≈ 25 ч приемлем (B3); квоты и полнота данных на реальном токене проверяются в QA до G4; «Государственный сектор» — в общем словаре и в каталогах (F6). Открытых findings для Specs и analysis нет: переход UID→FIGI после NOT_FOUND — design-решение B4, расхождение float/точно — принятый компромисс.
|
||||||
|
|
||||||
|
## ADR Candidates
|
||||||
|
|
||||||
|
- **ADR 022 «Хранимая классификация позиций (sector, brand) с суточным обновлением в часовом обходе»**, статус `Accepted` (решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)). Пишется в docs-задаче после реализации, как AS-IS (`docs/adr/022-stored-instrument-classification.md`, по analysis/docs-impact.md «ADR Decision»). Дополняет ADR 019 исключением, которое ввела PVΔ «Свежесть и fallback полного Portfolio Snapshot». Альтернативы — B и C из Solution Assessment. Последствия: первый долговременный справочник T-Bank, условие пересмотра при втором потребителе.
|
||||||
|
- Opt-in NOT_FOUND в `TbankClient` — локальное решение, не ADR.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Process Record
|
||||||
|
|
||||||
|
**Mode:** lab
|
||||||
|
**Class:** complex — межсистемно (backend + frontend + T-Bank), синхронизация внешних данных и финансовые расчёты долей
|
||||||
|
**Issue:** https://git.personal.ksv741.ru/WealthLedger/main/issues/48
|
||||||
|
|
||||||
|
## Change Profile
|
||||||
|
|
||||||
|
| Артефакт | Выбран | Причина |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| research | yes | Trigger: новый продуктовый функционал и вопрос владельца «какой ещё есть критерий?». Нужно сравнить критерии концентрации и формы визуализации в других продуктах. Итог — `analysis/research.md`. |
|
||||||
|
| ux-design | yes | Trigger: новый экран, вкладка «Концентрация» с четырьмя разрезами, выбором валюты, диаграммой и «Прочее». Отдельно нужно решить, как она соотносится с существующей «Структурой портфеля». |
|
||||||
|
| system-analysis | yes | Trigger: меняется контракт Portfolio, в позициях появляются сектор и эмитент. Новые данные берутся из внешней интеграции T-Bank (инструмент, актив и его бренд). |
|
||||||
|
| data-mapping | yes | Trigger: данные T-Bank (sector, asset → brand) нужно преобразовать в данные позиций Portfolio, а коды секторов — в русские подписи. Нужно правило для пустых и неизвестных значений. |
|
||||||
|
| failure-model | yes | Trigger: обогащение позиций идёт через внешнюю интеграцию в часовой синхронизации. Отказ или неполнота данных T-Bank не должны ломать Portfolio Snapshot и его свежесть (Open Decision 4 Proposal). |
|
||||||
|
| nfr | no | Число позиций одного счёта ограничено, вкладка считается по уже загруженному Portfolio. Нагрузку на T-Bank и лимиты запросов рассматривает failure-model; отдельных обязательств по задержке, объёму и хранению нет. |
|
||||||
|
| observability | no | Новых фоновых процессов нет: обогащение идёт внутри существующей синхронизации с её логированием. Недоступность данных видна пользователю как явные группы «не указан»; частичный успех обогащения (Snapshot сохранён, часть данных не получена) фиксируется в failure-model и существующих логах синхронизации без новых метрик и алертов. |
|
||||||
|
| security-analysis | no | Модель доступа и trust boundaries не меняются. Сектор и бренд — публичные справочные данные инструмента, к Portfolio того же User добавляются только они. Новые запросы к T-Bank идут под уже существующим токеном подключения того же User, новых секретов и прав нет. |
|
||||||
|
| test-cases | yes | Trigger: финансовые расчёты (суммы, доли, топ-5, «Прочее», сумма долей = 100 %) и много граничных случаев: одна валюта из нескольких, меньше пяти элементов, равные суммы, нет сектора или бренда, шорт, неоценённые позиции. Для проверки нужны конкретные данные и расчётный oracle. |
|
||||||
|
| docs-impact | yes | Trigger: несколько docs targets — домен Portfolio, страница интеграции T-Bank (новые используемые данные) и глоссарий (Концентрация, Сектор, Эмитент). |
|
||||||
|
|
||||||
|
## Gate Log
|
||||||
|
|
||||||
|
| Gate | Дата | Scope | Validation | Findings | Decision |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| G1 | 2026-10-30 | proposal, change-profile | `openspec validate` — ожидаемая ошибка «no deltas» до specs; readiness: plan OK (proposal, process-record, research); HTML-комментариев нет; `git diff --check` OK | G1 review independent-reviewer: 1 blocker, 4 major, 6 minor — закрыты решениями владельца ([issuecomment-3077](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)) и правками; повторный review: ready | approved "вроде все ок" |
|
||||||
|
| G1 | 2026-10-30 | proposal | — | G2 review B1: правило валют вкладки противоречит proposal | invalidated: решение владельца по B1 ([issuecomment-3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)) меняет правило валют в proposal |
|
||||||
|
| G1 | 2026-10-30 | proposal, change-profile | `openspec validate --strict` valid; readiness: plan OK; proposal обновлён по 3079 (свой список валют вкладки) | G2 re-review independent-reviewer: B1 закрыт в proposal | approved "Да, G1 ок" ([issuecomment-3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)) |
|
||||||
|
| G2 | 2026-10-30 | specs | `openspec validate --strict` valid; `git diff --check` OK; readiness: plan OK; self-check по spec-writing.md | G2 review qa-engineer + independent-reviewer: 1 blocker (B1, валюты), 4 major, ~12 minor — закрыты решениями владельца (3079, 3080) и правками; финальный re-review: ready | approved "да, идем дальше" |
|
||||||
|
| G2 | 2026-10-30 | specs | — | system-analysis/ux-design: эмитент у фондов, обновление metadata, имя эмитента при расхождении, поведение вкладки (сохранение выбора в пределах страницы, скрытый Select, метрика ≤5, partial) | invalidated: решения владельца ([issuecomment-3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) меняют Specs |
|
||||||
|
| G2 | 2026-10-30 | specs, system-analysis, data-mapping, failure-model | `openspec@1.13.1 validate --strict` valid; `validate --specs` 33/33; `git diff --check` OK; readiness: plan OK; Coverage 32/32 | G2 rev2/rev3 review independent-reviewer + qa-engineer: 1 blocker (B1 — противоречие canonical «Свежесть и fallback»), 3 major, ~8 minor — закрыты решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)) и правками; финальный re-review: ready | approved "ок, давай дальше" |
|
||||||
|
| G3 | 2026-10-31 | design, ux-design, qa-analysis, test-cases, docs-impact, tasks | `openspec@1.13.1 validate --strict` valid; `git diff --check` OK; readiness: apply OK; Visual Package v2 SHA-256 сверены reviewer; Coverage 32/32 | G3 review independent-reviewer + frontend-engineer: 4 major, 4 minor, Q2 — закрыты; backend-engineer не выполнен (квота API), backend-утверждения design сверены основным агентом с кодом; повторный review: ready. Включает изменение вне Specs: подпись «Государственный сектор» на странице акции и в каталогах (3084) | approved "да, все окей, идем дальше" |
|
||||||
|
|
||||||
|
Примечание к G2 (2026-10-30): GIVEN «сектор и бренд актива позиции ещё ни разу не получены» в Scenario «Недоступность сектора и эмитента не нарушает Snapshot» (finding N1) и замена строки design-обязательства в failure-model (N2) внесены основным агентом до записи approved G2 и входят в утверждённую версию.
|
||||||
|
|
||||||
|
## Descoped Work
|
||||||
|
|
||||||
|
| Task | Причина | Gate |
|
||||||
|
| --- | --- | --- |
|
||||||
|
|
||||||
|
## Lab Journal
|
||||||
|
|
||||||
|
| Стадия | Роль / сабагент | Уникальные находки | Решения | Риски найдены / исключены | Проблемы передачи | Skills | Стоимость | Переносимо |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| research | product-researcher | В Portfolio DTO нет сектора и эмитента, хотя T-Bank их отдаёт. Доля топ-5 понятнее HHI. Общий мультивалютный рейтинг без конвертации некорректен. «Структура» дублировала бы существующий виджет. | MVP: акции, облигации, секторы; позже: страна, maturity, riskLevel, P&L, HHI; не делать: кредитный рейтинг, конвертацию, treemap | Найден: полнота sector неизвестна. Исключено: смешение валют | Нет | — | 1 вызов | Шаблон «must / later / no» для выбора критериев |
|
||||||
|
| proposal | product-analyst + основной агент | Эмитент требует отдельной идентичности; основной агент нашёл в proto T-Bank `AssetFull.brand` {uid, name, company, sector} (краткий `Asset` бренда не содержит). 8 Open Decisions, 3 из них меняют scope | Владелец: «Эмитенты» в MVP (отклонение от research: Later → MVP), эмитент = бренд T-Bank, фонды не входят, госбумаги — по сектору T-Bank, режим lab | Найден: «Эмитенты» зависят от полноты бренда. Исключено: группировка по названию | Валидация change ждёт delta specs (ожидаемо) | — | 1 вызов + 2 вопроса владельцу | Решать scope-вопросы до G1 через короткий опрос |
|
||||||
|
| G1 review | independent reviewer + product-analyst | Источник бренда указан неточно (`Asset` вместо `AssetFull.brand`); «Секторы» не задавали набор типов; раскрытие «Прочее» было Open Decision; не зафиксировано отклонение от research | Владелец ([Issue #48, комментарий](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)): «Прочее» раскрывается; без бренда — fallback «Эмитент не указан» без нового G1; «Секторы» только STOCK/BOND/FUND; общий топ-5 вне scope | Найден: несоответствие источника и scope. Исключено: зависимость «Эмитентов» от полноты бренда | Proposal правился после review; Gate Log не заполнялся | — | 1 вызов | Сверять ссылки на proto с исходником до G1 |
|
||||||
|
| specs | product-analyst | Остальные нормы вкладки не требуют новых источников; в canonical нет перечня вкладок страницы счёта и правил выбора валюты «Структуры портфеля» (они только в коде и docs) — правила валюты, округления, равных сумм определены в Specs и вынесены на подтверждение G2. Эмитент и сектор — новые поля позиции Portfolio | 2 дельты: новая `portfolio-concentration` (23 Requirement), `portfolio-view` MODIFIED «Нормализованный состав Portfolio». Решения владельца ([3078](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)) внесены | Найден: расхождение «Структуры портфеля» (деньги в знаменателе и в валютах) и вкладки (без денег) — различие зафиксировано явно | Нет | — | 1 вызов | Сверять правила «как в существующем виджете» с canonical до написания, а не с кодом |
|
||||||
|
| G2 review | qa-engineer + independent-reviewer + product-analyst | Валюты вкладки не были определены независимо от «Структуры»; «Прочее» при шести элементах скрывало одну строку; метод наибольшего остатка противоречил показу долей в карточках; подписи секторов без источника; не было граничных сценариев 5/6 элементов и противоречивый GIVEN | Владелец ([3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)): свой список валют вкладки; округление как в карточках, «< 0,1 %»; «Прочее» от семи элементов. Автор: список валют включает отрицательные и нулевые позиции STOCK/BOND/FUND; `null` считается в сноске при любой валюте | Найден: расхождение формата долей между спеками и кодом. Исключено: инвариант «100,0 %» на показанных значениях | Gate Log ведёт основной агент (G1 инвалидирован) | — | 1 вызов | Сверять формат чисел с кодом существующего виджета до написания норм |
|
||||||
|
| specs rev2 | product-analyst | G2 инвалидирован решениями [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081): issuer у фондов в данных; обновление sector/brand раз в сутки с последним известным значением; отказ token — как существующая недействительность; trim пробелов; имя эмитента по наибольшей позиции; сохранение выбора в пределах страницы; метрика топ-5 при ≥6 элементах; центр кольца; «Данные неполные» | `portfolio-concentration` 28 Requirement (+5: вкладка в состояниях страницы, название эмитента при разных названиях, центр диаграммы, строки без переходов, предупреждение о неполных данных; «Суммарная доля топ-5» и «Начальное состояние» переписаны); `portfolio-view` MODIFIED + 3 ADDED (срок обновления, последнее известное, ошибки получения) | Найден: «Данные неполные» привязано к `meta` по `tbank-data-sync`, не к неоценённым позициям. Исключено: конфликт metadata/invalid token (отсылка к существующему поведению) | Gate Log ведёт основной агент | — | 1 вызов | Сверять производные analysis-артефакты с Delta Specs до G2 |
|
||||||
|
| specs rev3 | product-analyst | G2 re-review: хранение sector/brand противоречило «Свежесть и fallback» (только онлайн, без долговременного справочника) и «metadata не определяет успешность»; граница 24 ч была сформулирована как «24 ч + интервал»; «не найдено», пустой ответ и ответ о другом активе не были определены | [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082): исключение для sector/brand; «не найдено» = «значения нет»; равные наибольшие позиции эмитента — по алфавиту; предупреждение о неполных данных только на вкладке | `portfolio-view`: MODIFIED «Свежесть и fallback» (полная копия + 2 исключения и 2 сценария), ADDED «Обновление сектора и эмитента» и «Результат получения сектора и эмитента» (таблица исходов), без дублей; `portfolio-concentration`: название эмитента по алфавиту, предупреждение без дубля | Найден: страничного сообщения о неполноте в canonical «Состояния и ошибки Portfolio на клиенте» нет (требует лишь не выдавать неполный итог за окончательный), поэтому MODIFIED для него не нужен. Исключено: дублирование норм об ошибках | Gate Log ведёт основной агент | — | 1 вызов | Если новая норма хранит данные, сверять с общими «только онлайн» правилами до написания |
|
||||||
|
| design ∥ qa-analysis | solution-architect, qa-engineer | Общий metadata-кэш не подходит под 24 ч и 4 исхода, нужна отдельная таблица классификации; шаг классификации должен идти до publish, иначе отказ token не инвалидирует подключение; DonutChart 9.7.3 хватает без прямого recharts | Таблица `TbankInstrumentClassification`, opt-in NOT_FOUND только для новых вызовов, состояние вкладки поднято в page VM; владелец: ~25 ч, квоты в QA до G4, «Государственный сектор» в общем словаре (3084) | Найдено: изменение страницы акции вне Specs (одобрено 3084); откат образа без миграции | Субагенты не видели полный текст review, findings пересылались вручную | — | 2 вызова + 3 доработки | Сверять design-ссылки на код до G3 |
|
||||||
|
| test-cases + docs-impact + tasks | qa-engineer, documentation-engineer, solution-architect | 21 из 30 зарезервированных TC ничего не добавляли сверх Scenario и выброшены; «эмитент» конфликтует с термином docs | 15 TC + TC-CONC-020; глоссарий с оговоркой, ADR 022 (3083); 28 tasks по 4 repo | Найдено: 2 неверных oracle (TC-CONC-007, TC-META-003) | — | — | 3 вызова | Пересчитывать oracle независимым ревьюером |
|
||||||
|
| G3 review | independent-reviewer, frontend-engineer; backend-engineer — сбой по квоте API (3 попытки) | 4 major, 4 minor, Q2 (откат без миграции); frontend API DonutChart подтверждён | Все findings закрыты; backend-утверждения design (до publish, InvalidToken catch, NOT_FOUND в `call()`, `WORK_WAIT_BOUND_MS`) основной агент сверил с кодом вручную | Остаётся: backend-ревью субагентом не выполнено | Квота API | — | 5 вызовов | Ревью backend повторить на G4 |
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
Issue: WealthLedger/main#48
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Страница счёта показывает состав Portfolio списком позиций и распределением по классам активов. Ответа на вопрос «где сконцентрированы деньги» нет: чтобы понять, какие бумаги, отрасли и компании занимают основную часть счёта, владельцу приходится вручную сравнивать карточки позиций. Распределение по классам активов этого не даёт: оно не показывает ни крупнейшие позиции, ни отраслевой или эмитентский перекос.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- На странице счёта рядом с вкладкой «Позиции» появляется вкладка «Концентрация».
|
||||||
|
- Вкладка содержит четыре разреза текущего состава Portfolio выбранного счёта: «Акции», «Облигации», «Секторы», «Эмитенты».
|
||||||
|
- В каждом разрезе показаны пять крупнейших элементов по текущей денежной оценке, каждый с суммой и долей, а при семи и более элементах все остальные объединены в одну строку «Прочее» (при шести или меньше элементах каждый показан своей строкой). По нажатию на «Прочее» раскрывается полный перечень остальных элементов, каждый с суммой и долей; пять первых строк при этом не меняются. Рядом с перечнем показана диаграмма распределения, согласованная с перечнем, и суммарная доля пяти первых элементов как показатель концентрации.
|
||||||
|
- «Акции» и «Облигации» ранжируют позиции типов `STOCK` и `BOND` соответственно. Фонды в эти два разреза не входят.
|
||||||
|
- «Секторы» агрегируют только позиции типов `STOCK`, `BOND` и `FUND`. Позиции типов `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` и `OTHER` в разрез не входят. Позиции перечисленных трёх типов без сектора попадают в явную группу «Сектор не указан».
|
||||||
|
- «Секторы» используют сектор инструмента в том виде, как его определяет T-Bank, с русской подписью; государственные облигации определяются значением сектора T-Bank `government` с подписью «Государственный сектор», других собственных классификаций WealthLedger не вводит.
|
||||||
|
- «Эмитенты» объединяют акции и облигации одной компании в один элемент. Эмитент — бренд актива по данным T-Bank; название эмитента — название бренда (при разных названиях одного бренда — название позиции с наибольшей стоимостью). Эмитент заполняется в данных Portfolio и у фондов, а разрез «Эмитенты» фонды отбрасывает. Позиции без бренда попадают в явную группу «Эмитент не указан» с видимой долей; если бренда нет у многих позиций, разрез всё равно остаётся в MVP, а объём scope не меняется. Фонды в разрез не входят: их эмитент — управляющая компания, а не компании внутри фонда.
|
||||||
|
- Значения показываются в одной валюте за раз. У вкладки свой список валют и свой выбор, независимые от «Структуры портфеля»: в списке валюты позиций акций, облигаций и фондов; по умолчанию выбрана первая по ISO-коду. При одной валюте выбор не показывается. Конвертации нет, общего итога по нескольким валютам нет. Выбранные разрез и валюта сохраняются, пока User остаётся на странице счёта, и сбрасываются при новом заходе и перезагрузке. Доли показываются в формате карточек позиций (обычное округление до одного знака, «< 0,1 %» для очень малых).
|
||||||
|
- Для разрезов «Секторы» и «Эмитенты» в данных позиций Portfolio появляются сектор и признак эмитента. Это расширение ответа Portfolio новыми необязательными данными; существующие данные позиций и их смысл не меняются. Изменение не является BREAKING.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Вкладка «Концентрация» на странице одного счёта.
|
||||||
|
- Разрезы «Акции», «Облигации», «Секторы», «Эмитенты» с топ-5, «Прочее», суммой и долей по элементам, диаграммой распределения и суммарной долей топ-5.
|
||||||
|
- Раскрытие «Прочее» по нажатию с полным перечнем остальных элементов.
|
||||||
|
- Явные группы «Сектор не указан» и «Эмитент не указан» с видимой долей.
|
||||||
|
- Выбор одной валюты, пересчёт всех разрезов вкладки под выбранную валюту.
|
||||||
|
- Состояния вкладки при отсутствии позиций в выбранной валюте, при отсутствии нужных данных (сектор, эмитент) и при ошибке или устаревании Portfolio — в рамках уже действующего поведения Portfolio.
|
||||||
|
- Расширение данных позиций Portfolio сектором и признаком эмитента (аддитивно).
|
||||||
|
- Обновление AS-IS документации домена Portfolio, интеграции T-Bank и глоссария (термины «Концентрация», «Сектор», «Эмитент»).
|
||||||
|
|
||||||
|
### Out of Scope / Non-Goals
|
||||||
|
|
||||||
|
- Общий топ-5 по всем типам позиций вместе (исходная формулировка Issue): рейтинги строятся только внутри разрезов «Акции», «Облигации», «Секторы», «Эмитенты».
|
||||||
|
- Страна риска и региональное распределение.
|
||||||
|
- Сроки погашения облигаций.
|
||||||
|
- Лидеры роста и падения, доход по позициям и вклад выплат в доход.
|
||||||
|
- Показатель `riskLevel` T-Bank и кредитный рейтинг агентств.
|
||||||
|
- Индекс концентрации (HHI) и эффективное число позиций.
|
||||||
|
- Конвертация валют, единый рейтинг или единая диаграмма по нескольким валютам.
|
||||||
|
- Замена, изменение или повтор существующей «Структуры портфеля» и вкладки «Позиции».
|
||||||
|
- Режим «Все счета»: вкладка относится только к странице одного счёта.
|
||||||
|
- Изменение порядка, состава и свежести Portfolio Snapshot.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `specs/portfolio-concentration/spec.md`: вкладка «Концентрация» — разрезы, ранжирование топ-5, «Прочее», доли и суммарная доля топ-5, выбор валюты, правила учёта позиций, состояния.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `specs/portfolio-view/spec.md`: нормализованный состав Portfolio расширяется сектором и эмитентом позиции (требование «Нормализованный состав Portfolio»); добавляются требования о сроке обновления, последнем известном значении и ошибках получения сектора и эмитента. Требование «Точные значения и валютные агрегаты» не меняется; новая capability `portfolio-concentration` на него ссылается.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Владелец счёта получает новый экран анализа; существующие экраны и их поведение не меняются.
|
||||||
|
- Затрагиваются backend (обогащение данных Portfolio), frontend (новая вкладка и её разрезы), specs и AS-IS документация.
|
||||||
|
- Ответ Portfolio расширяется аддитивно: клиенты, не знающие новых данных, продолжают работать без изменений. Публичные API-артефакты потребуют обновления.
|
||||||
|
- Часовая синхронизация с T-Bank дополнительно получает данные об инструментах и брендах для позиций; это может увеличить число обращений к T-Bank и время синхронизации. Оценка нагрузки и способ получения — в Design.
|
||||||
|
- Сбой или неполнота этих данных не должны ухудшать успешность и свежесть Portfolio Snapshot (решено, см. Open Decisions); без новых данных позиции показываются в явных группах «Сектор не указан» и «Эмитент не указан».
|
||||||
|
- Сектор и бренд актива обновляются не чаще раза в сутки (значения могут отставать от T-Bank не более чем на сутки плюс интервал часового обхода); при неуспешном обновлении сохраняется последнее известное значение. Пробелы по краям обрезаются, значение из одних пробелов считается отсутствующим. Отказ token при получении сектора или бренда обрабатывается как существующая недействительность подключения T-Bank.
|
||||||
|
- На вкладке: в центре диаграммы «Топ-5 · N %» (при шести и более элементах) и сумма разреза, без логотипов и переходов в строках, предупреждение «Данные неполные» при неполном Portfolio; вкладки нет при загрузке, ошибке и пустом Portfolio.
|
||||||
|
- Существующие данные Portfolio, порядок позиций и итоги по валютам не меняются.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- На странице счёта вкладка «Концентрация» доступна рядом с «Позиции»; вкладка «Позиции» и «Структура портфеля» выглядят и работают как раньше.
|
||||||
|
- В каждом из четырёх разрезов при выбранной валюте при семи и более элементах видны пять строк и «Прочее», при шести или меньше — отдельные строки без «Прочее»; точная сумма строк равна общей сумме разреза в этой валюте.
|
||||||
|
- Доля каждого элемента и суммарная доля первых пяти считаются от одной и той же базы, а диаграмма и перечень показывают одни и те же элементы и значения; доли показаны в формате карточек позиций и могут в сумме отличаться от 100 % из-за округления.
|
||||||
|
- Суммы точны и не конвертируются: при выборе валюты в разрезах нет позиций других валют.
|
||||||
|
- Нажатие на «Прочее» раскрывает все остальные элементы разреза с суммой и долей каждого; состав и порядок топ-5 не меняются.
|
||||||
|
- В разрезе «Секторы» присутствуют только позиции `STOCK`, `BOND`, `FUND`; позиции других типов в нём отсутствуют. Позиция этих типов без сектора учитывается в группе «Сектор не указан».
|
||||||
|
- Акция и облигация одного эмитента в разрезе «Эмитенты» показываются одним элементом с общей суммой; фонд в этом разрезе отсутствует.
|
||||||
|
- Позиция без бренда учитывается в группе «Эмитент не указан», и её доля видна пользователю.
|
||||||
|
- Общего топ-5 по всем типам позиций на вкладке нет.
|
||||||
|
- Позиция без вычислимой текущей стоимости не влияет на суммы и доли.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Исследование: `analysis/research.md` (ссылки в формате его источников; здесь не копируются).
|
||||||
|
|
||||||
|
- [F] Потребность: показать крупнейшие акции, облигации и секторы по денежной оценке с переключением критерия и диаграммой распределения. Источник: запрос Change Owner, WealthLedger/main#48; `analysis/research.md`, Problem Statement.
|
||||||
|
- [F] Страница счёта уже содержит распределение по классам активов с выбором одной валюты и вкладку «Позиции» с разделением «Акции / Облигации / Прочее». Источник: `analysis/research.md`, Problem Statement.
|
||||||
|
- [F] Каноническая `portfolio-view` фиксирует отсутствие валютной конвертации и отдельные итоги по каждой валюте; позиция без вычислимой текущей стоимости остаётся в ответе, но не входит в агрегаты; в Portfolio входят только позиции с ненулевым количеством. Источник: `specs/portfolio-view/spec.md`, требования «Нормализованный состав Portfolio», «Точные значения и валютные агрегаты».
|
||||||
|
- [F] Карточки позиций показывают долю позиции среди позиций той же валюты. Источник: `specs/portfolio-view/spec.md`, требование «Представление карточек позиций на клиенте».
|
||||||
|
- [F] Текущие данные позиции Portfolio не содержат сектора и эмитента. Источник: `analysis/research.md` [L4].
|
||||||
|
- [F] Сектор есть у инструментов Share, Bond и Etf в интеграционном описании T-Bank. Источник: `backend/src/integrations/tbank/proto/contracts/instruments.proto` (Share, Bond, Etf, поле `sector`).
|
||||||
|
- [F] Бренд с компанией и сектором (`Brand`, :1220-1229) доступен в полном описании актива `AssetFull.brand` (:1051-1067). Краткое описание актива `Asset` бренда не содержит, а `BrandData` внутри инструментов хранит только данные логотипа. Ссылка инструмента на актив (`asset_uid`) есть у Share (:776), Bond (:543) и Etf (:657). Источник: тот же `instruments.proto`; `analysis/research.md` [L9].
|
||||||
|
- [F] Разрез «Эмитенты» перенесён из Later research в MVP решением Change Owner (комментарий в Issue #48, issuecomment-3077): условие research «после подтверждения issuer identity» снято наличием `AssetFull.brand`.
|
||||||
|
- [F] Группировка по эмитенту акций и облигаций описана в публичной справке Т-Инвестиций; секторное распределение подтверждено несколькими продуктами. Источник: `analysis/research.md`, Evidence & Sources [E1, E3, E6, E7].
|
||||||
|
- [F] Внешние продукты — источник кандидатов, но не доказательство спроса. Источник: `analysis/research.md`, Target Audience.
|
||||||
|
- [H] Бренд актива T-Bank пригоден как идентичность эмитента; полнота бренда на позициях владельца неизвестна. Группировка по совпадающему названию позиций ненадёжна и не используется. Fallback согласован владельцем: нет бренда → «Эмитент не указан»; полнота проверяется в data-mapping.
|
||||||
|
- [H] У позиций владельца сектор заполнен в большинстве случаев; доля стоимости без сектора и состав значений (включая «государственные ценные бумаги») неизвестны. Проверяется до Specs на данных Portfolio владельца.
|
||||||
|
- [H] Сектор у фондов в данных T-Bank присутствует не всегда. Проверяется вместе с предыдущей гипотезой.
|
||||||
|
- [H] Отрицательная текущая стоимость (короткая позиция) возможна в Portfolio. Спецификация допускает отрицательные значения, но наличие таких позиций у владельца не подтверждено.
|
||||||
|
- [H] Недоступность сектора или эмитента не должна ломать загрузку Portfolio — по аналогии с логотипом, который является только presentation metadata. Не подтверждено.
|
||||||
|
|
||||||
|
## Assumptions / Dependencies
|
||||||
|
|
||||||
|
- Базой для вкладки служит сохранённый Portfolio Snapshot со своей свежестью и статусами; вкладка не запускает собственных обновлений.
|
||||||
|
- У вкладки собственный выбор валюты, независимый от «Структуры портфеля» (решение владельца, [комментарий 3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)).
|
||||||
|
- «Структура портфеля» остаётся отдельным блоком; её замена вкладкой «Концентрация» не планируется.
|
||||||
|
- Данные о секторе и бренде берутся из T-Bank; способ получения и хранения — предмет Design.
|
||||||
|
- Пять элементов — фиксированный размер топа для всех разрезов; настройка размера не нужна.
|
||||||
|
- Элементы разрезов «Акции» и «Облигации» — отдельные позиции; одна и та же бумага на одном счёте с одинаковым FIGI не объединяется (действующее правило `portfolio-view`).
|
||||||
|
|
||||||
|
## Open Decisions
|
||||||
|
|
||||||
|
Все вопросы решает Change Owner. Ни один из них не меняет набор разрезов и не требует нового G1.
|
||||||
|
|
||||||
|
**Решены Change Owner до G1** (дата 2026-10-30; дословные формулировки — [комментарий в Issue #48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077); решения внесены в What Changes):
|
||||||
|
|
||||||
|
- Эмитент — бренд актива T-Bank; позиции без бренда показываются в группе «Эмитент не указан» с видимой долей. Если бренда нет у многих позиций, разрез остаётся, scope не меняется, новый G1 не нужен.
|
||||||
|
- Фонды в разрез «Эмитенты» не входят.
|
||||||
|
- Государственные облигации в «Секторах» — по сектору, как его определяет T-Bank, без собственной классификации.
|
||||||
|
- «Секторы» включают только `STOCK`, `BOND`, `FUND`; остальные типы позиций в разрез не входят.
|
||||||
|
- «Прочее» раскрывается по нажатию с полным перечнем остальных элементов; состав топ-5 не меняется.
|
||||||
|
- Общий топ-5 по всем типам из исходного Issue — вне scope.
|
||||||
|
- Отклонение от research: там «Эмитенты» были в Later «после подтверждения issuer identity». Условие снято, так как бренд актива T-Bank (`AssetFull.brand`) даёт стабильную идентичность компании; полнота бренда на позициях владельца остаётся гипотезой, её покрывает fallback «Эмитент не указан».
|
||||||
|
|
||||||
|
**Решены Change Owner до G2** (дата 2026-10-30; дословные формулировки — [комментарий в Issue #48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078); решения внесены в Delta Specs):
|
||||||
|
|
||||||
|
- Знаменатель доли — сумма учитываемых позиций разреза в выбранной валюте; денежные остатки в него не входят.
|
||||||
|
- Позиции с отрицательной стоимостью и без оценки исключаются из ранжирования, долей и диаграммы; сноска показывает, сколько позиций не учтено.
|
||||||
|
- Группы «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными элементами и заметно обозначены.
|
||||||
|
- Отсутствие сектора или эмитента не влияет на успешность и свежесть Portfolio Snapshot, как и логотип; у позиций в таких случаях пустые значения.
|
||||||
|
|
||||||
|
**Решены Change Owner после G2-review** ([комментарий 3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079); внесены в Delta Specs):
|
||||||
|
|
||||||
|
- Вкладка имеет собственный список валют и выбор, независимые от «Структуры портфеля».
|
||||||
|
- Доли показываются с округлением карточек позиций; точные доли в сумме дают 100 %.
|
||||||
|
- «Прочее» появляется только при семи и более элементах.
|
||||||
|
|
||||||
|
**Подтверждены Change Owner** ([комментарий 3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080); внесены в Delta Specs):
|
||||||
|
|
||||||
|
- Список валют вкладки: валюты ненулевых `currentValue` позиций акций, облигаций и фондов, включая отрицательные; валюта только с нулевой стоимостью в список не входит.
|
||||||
|
- В сноске «Не учтено позиций» позиции без оценки считаются при любой выбранной валюте; при пропаже выбранной валюты берётся первая из списка; равные суммы упорядочиваются по названию.
|
||||||
|
- Название эмитента — название бренда T-Bank.
|
||||||
|
- Подпись сектора `government` — «Государственный сектор» (добавление WealthLedger к подписям скринера).
|
||||||
|
|
||||||
|
**Подтверждены Change Owner** ([комментарий 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081); внесены в Delta Specs):
|
||||||
|
|
||||||
|
- Эмитент заполняется у фондов в данных Portfolio, разрез «Эмитенты» их отбрасывает.
|
||||||
|
- Отказ token при получении сектора или бренда — как существующая недействительность подключения.
|
||||||
|
- Сектор и бренд обновляются раз в сутки; при ошибке сохраняется последнее известное значение.
|
||||||
|
- Пробелы обрезаются; при разных названиях одного эмитента берётся название позиции с наибольшей стоимостью.
|
||||||
|
- Разрез и валюта сохраняются в пределах страницы счёта; выбор валюты скрыт при одной валюте; метрика «доля топ-5» скрыта при пяти и менее элементах; при неполных данных — предупреждение «Данные неполные»; в центре диаграммы «Топ-5 · N %» и сумма; в «Эмитентах» без логотипов и переходов.
|
||||||
|
|
||||||
|
Открытых решений нет.
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
# portfolio-concentration Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Позволяет авторизованному User увидеть на странице одного Brokerage Account, какие акции, облигации, секторы и эмитенты занимают наибольшую часть текущей оценки Portfolio в выбранной валюте, без валютной конвертации.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Вкладка «Концентрация» страницы счёта
|
||||||
|
Система SHALL показывать на странице одного Brokerage Account вкладку «Концентрация» рядом с вкладкой «Позиции». Система SHALL NOT показывать вкладку в режиме «Все счета». Добавление вкладки SHALL NOT менять вкладку, открываемую по умолчанию, и содержание остальных вкладок.
|
||||||
|
|
||||||
|
#### Scenario: Вкладка на странице одного счёта
|
||||||
|
- **WHEN** User открывает страницу доступного Brokerage Account
|
||||||
|
- **THEN** рядом с вкладкой «Позиции» доступна вкладка «Концентрация», а по умолчанию открыта прежняя вкладка
|
||||||
|
|
||||||
|
#### Scenario: Режим «Все счета»
|
||||||
|
- **WHEN** User открывает общий портфель в режиме «Все счета»
|
||||||
|
- **THEN** вкладки «Концентрация» нет
|
||||||
|
|
||||||
|
### Requirement: Вкладка в состояниях страницы
|
||||||
|
Система SHALL NOT показывать вкладку «Концентрация», пока Portfolio загружается, при его ошибке или недоступности и при штатном пустом Portfolio по требованию «Состояния и ошибки Portfolio на клиенте» из `portfolio-view`. Вкладка SHALL показываться при Portfolio хотя бы с одной позицией или денежным остатком.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||||
|
|
||||||
|
#### Scenario: Загрузка и ошибка
|
||||||
|
- **WHEN** Portfolio загружается либо получение завершилось ошибкой
|
||||||
|
- **THEN** вкладки «Концентрация» нет
|
||||||
|
|
||||||
|
#### Scenario: Штатный пустой Portfolio
|
||||||
|
- **WHEN** у счёта нет позиций и денежных остатков
|
||||||
|
- **THEN** вкладки «Концентрация» нет
|
||||||
|
|
||||||
|
#### Scenario: Только денежные остатки
|
||||||
|
- **WHEN** у счёта есть Cash Balance и нет позиций
|
||||||
|
- **THEN** вкладка «Концентрация» есть, разрез показывает пустое состояние
|
||||||
|
|
||||||
|
### Requirement: Переключение разрезов
|
||||||
|
Вкладка «Концентрация» SHALL предоставлять разрезы «Акции», «Облигации», «Секторы», «Эмитенты» в этом порядке и показывать одновременно один из них. При новом заходе на страницу счёта SHALL быть открыт разрез «Акции» по требованию «Начальное состояние вкладки». Разрез — набор элементов, на которые делится оценка позиций; элемент — единица ранжирования разреза по требованию «Состав разрезов».
|
||||||
|
|
||||||
|
#### Scenario: Открытие вкладки
|
||||||
|
- **WHEN** User при новом заходе на страницу счёта открывает вкладку «Концентрация»
|
||||||
|
- **THEN** показан только разрез «Акции» и доступны переключатели «Акции», «Облигации», «Секторы», «Эмитенты»
|
||||||
|
|
||||||
|
#### Scenario: Переключение разреза
|
||||||
|
- **GIVEN** открыт разрез «Акции» с выбранной валютой USD
|
||||||
|
- **WHEN** User переключает разрез на «Секторы»
|
||||||
|
- **THEN** показан только разрез «Секторы» в валюте USD
|
||||||
|
|
||||||
|
### Requirement: Начальное состояние вкладки
|
||||||
|
Система SHALL открывать вкладку «Концентрация» при новом заходе на страницу счёта и после перезагрузки с разрезом «Акции» и первой валютой списка. Новый заход — открытие страницы после ухода с неё либо переключение счёта. Пока User остаётся на странице, включая переключение её вкладок, разрез и валюта SHALL сохраняться.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||||
|
|
||||||
|
#### Scenario: Возврат с другой вкладки страницы
|
||||||
|
- **GIVEN** на вкладке выбраны разрез «Секторы» и валюта USD
|
||||||
|
- **WHEN** User переключается на вкладку «Позиции» и возвращается на «Концентрация»
|
||||||
|
- **THEN** выбраны разрез «Секторы» и валюта USD
|
||||||
|
|
||||||
|
#### Scenario: Повторный заход
|
||||||
|
- **GIVEN** User выбрал разрез «Секторы» и валюту USD, затем покинул страницу счёта
|
||||||
|
- **WHEN** User снова открывает страницу счёта и вкладку «Концентрация»
|
||||||
|
- **THEN** открыт разрез «Акции» с первой валютой списка
|
||||||
|
|
||||||
|
#### Scenario: Перезагрузка
|
||||||
|
- **GIVEN** на вкладке выбраны разрез «Эмитенты» и валюта USD
|
||||||
|
- **WHEN** User перезагружает страницу
|
||||||
|
- **THEN** после открытия вкладки показаны разрез «Акции» и первая валюта списка
|
||||||
|
|
||||||
|
### Requirement: Состав разрезов
|
||||||
|
Элементы разреза SHALL формироваться из позиций типов по таблице; другие типы, включая `CURRENCY`, SHALL NOT входить во вкладку.
|
||||||
|
|
||||||
|
| Разрез | Типы | Элемент |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Акции | `STOCK` | Позиция |
|
||||||
|
| Облигации | `BOND` | Позиция |
|
||||||
|
| Секторы | `STOCK`, `BOND`, `FUND` | Позиции с одним `sector` |
|
||||||
|
| Эмитенты | `STOCK`, `BOND` | Позиции с одним `issuer.id` |
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)
|
||||||
|
|
||||||
|
#### Scenario: Сектор объединяет акции, облигации и фонды
|
||||||
|
- **GIVEN** в RUB есть акция сектора `energy` на 400, облигация `energy` на 100, фонд `financial` на 300 и позиция `CURRENCY` на 50
|
||||||
|
- **WHEN** User открывает разрез «Секторы» в RUB
|
||||||
|
- **THEN** элементы — «Энергетика» 500 ₽ и «Финансы» 300 ₽, позиции `CURRENCY` в разрезе нет
|
||||||
|
|
||||||
|
#### Scenario: Акция и облигация одного эмитента
|
||||||
|
- **GIVEN** в RUB есть акция с `issuer.id` `brand-1` («Сбер») на 300, облигация `brand-1` на 200 и фонд `brand-1` на 100
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты» в RUB
|
||||||
|
- **THEN** есть один элемент «Сбер» на 500 ₽, фонд в него не включён
|
||||||
|
|
||||||
|
#### Scenario: Фонд вне «Акций»
|
||||||
|
- **GIVEN** в RUB есть фонд на 100 и акция на 200
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** показана только акция на 200 ₽ с долей 100 %
|
||||||
|
|
||||||
|
### Requirement: Названия элементов
|
||||||
|
Название элемента в «Акциях» и «Облигациях» SHALL быть отображаемым идентификатором позиции по требованию «Нормализованный состав Portfolio» из `portfolio-view` (name, затем ticker, затем FIGI), в «Секторах» — подписью по требованию «Подписи секторов», в «Эмитентах» — `issuer.name`, то есть названием бренда актива T-Bank. Позиции с разными `issuer.id` SHALL NOT объединяться при одинаковом `issuer.name`.
|
||||||
|
|
||||||
|
#### Scenario: Идентификатор позиции без имени
|
||||||
|
- **GIVEN** акция не имеет name, но имеет ticker `SBER`
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** элемент назван `SBER`
|
||||||
|
|
||||||
|
#### Scenario: Название эмитента — название бренда
|
||||||
|
- **GIVEN** акция и облигация имеют `issuer.id` `brand-1` и `issuer.name` «Сбер»
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты»
|
||||||
|
- **THEN** один элемент назван «Сбер»
|
||||||
|
|
||||||
|
#### Scenario: Разные эмитенты с одинаковым названием
|
||||||
|
- **GIVEN** две акции имеют `issuer.name` «Альфа» и разные `issuer.id`
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты»
|
||||||
|
- **THEN** показаны два отдельных элемента «Альфа»
|
||||||
|
|
||||||
|
### Requirement: Название эмитента при разных названиях бренда
|
||||||
|
Если учитываемые позиции одного `issuer.id` имеют разные `issuer.name`, название элемента SHALL быть `issuer.name` позиции с наибольшим `currentValue` в выбранной валюте. При равных значениях SHALL выбираться название, меньшее по алфавитному порядку требования «Порядок элементов с равной суммой».
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||||
|
|
||||||
|
#### Scenario: Название по наибольшей позиции
|
||||||
|
- **GIVEN** акция `brand-1` на 300 с названием «Сбер» и облигация `brand-1` на 200 с названием «Сбербанк»
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты»
|
||||||
|
- **THEN** один элемент на 500 назван «Сбер»
|
||||||
|
|
||||||
|
#### Scenario: Равные наибольшие значения
|
||||||
|
- **GIVEN** акция `brand-1` на 200 с названием «Сбербанк» и облигация `brand-1` на 200 с названием «Сбер»
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты»
|
||||||
|
- **THEN** элемент назван «Сбер»
|
||||||
|
|
||||||
|
### Requirement: Позиции, учитываемые в разрезе
|
||||||
|
Система SHALL учитывать позицию в ранжировании, долях и диаграмме только при `currentValue` больше нуля в выбранной валюте; остальные позиции SHALL NOT учитываться. Позиция с нулевым `currentValue` не показывается и не считается неучтённой, позиция другой валюты относится к другой валюте выбора.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||||
|
|
||||||
|
#### Scenario: Отрицательная и неоценённая позиции
|
||||||
|
- **GIVEN** в разрезе «Акции» в RUB есть акции на 600 и 400, акция со стоимостью -50 и акция без `currentValue`
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** сумма разреза 1 000 ₽, доли 60 % и 40 %
|
||||||
|
|
||||||
|
#### Scenario: Позиция с нулевой стоимостью
|
||||||
|
- **GIVEN** есть акции на 100 и 0 в выбранной валюте
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** показана только акция на 100 с долей 100 %, сноски нет
|
||||||
|
|
||||||
|
### Requirement: Сноска о не учтённых позициях
|
||||||
|
Система SHALL показывать сноску «Не учтено позиций: N» при N больше нуля. N — число позиций разреза с `currentValue` `null` (в любой валюте) либо отрицательным в выбранной.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080). Примечание: при неполных данных «Структура портфеля» показывает «Доля недоступна», вкладка — доли по учтённым ([3078](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)).
|
||||||
|
|
||||||
|
#### Scenario: Сноска с числом позиций
|
||||||
|
- **GIVEN** в разрезе «Акции» в RUB есть акции на 600 и 400, акция со стоимостью -50 и акция без `currentValue`
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** показана сноска «Не учтено позиций: 2»
|
||||||
|
|
||||||
|
#### Scenario: Неоценённая позиция при смене валюты
|
||||||
|
- **GIVEN** есть акции на 100 USD и 200 RUB и одна акция без `currentValue`
|
||||||
|
- **WHEN** User выбирает USD
|
||||||
|
- **THEN** показаны акция на 100 $ с долей 100 % и сноска «Не учтено позиций: 1»
|
||||||
|
|
||||||
|
#### Scenario: Отрицательная позиция другой валюты
|
||||||
|
- **GIVEN** есть акции на 100 RUB и -50 USD
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** сноски нет
|
||||||
|
|
||||||
|
#### Scenario: Нет не учтённых позиций
|
||||||
|
- **GIVEN** все акции имеют положительную стоимость
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** сноски нет
|
||||||
|
|
||||||
|
### Requirement: Одна валюта за раз
|
||||||
|
Система SHALL показывать значения вкладки в одной выбранной валюте, общей для всех разрезов и сохраняемой при их переключении, и при нескольких доступных валютах предоставлять выбор из них. Смена валюты SHALL пересчитывать разрез по позициям этой валюты. Система SHALL NOT конвертировать валюты и показывать общий итог нескольких валют.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||||
|
|
||||||
|
#### Scenario: Смена валюты
|
||||||
|
- **GIVEN** акции стоят 1 000 RUB и 100 USD, выбрана RUB
|
||||||
|
- **WHEN** User выбирает USD
|
||||||
|
- **THEN** разрез «Акции» показывает только акции в USD, сумму 100 $ и долю 100 %, без рублёвых значений
|
||||||
|
|
||||||
|
#### Scenario: Валюта сохраняется при смене разреза
|
||||||
|
- **GIVEN** выбрана USD в разрезе «Акции»
|
||||||
|
- **WHEN** User переключает разрез на «Облигации»
|
||||||
|
- **THEN** показаны облигации в USD
|
||||||
|
|
||||||
|
### Requirement: Доступные валюты вкладки
|
||||||
|
Доступные валюты — валюты ненулевых `currentValue` позиций `STOCK`, `BOND` и `FUND`, включая отрицательные (для сноски). Валюта только с нулевой стоимостью в список не входит. Cash Balance и другие типы валюты не определяют. Список упорядочен по ISO-коду, сначала выбрана первая. При одной валюте или без валют выбор SHALL NOT показываться.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||||
|
|
||||||
|
#### Scenario: Начальная валюта
|
||||||
|
- **GIVEN** акции оценены в USD, облигации в RUB, а Cash Balance есть ещё в EUR и позиция `CURRENCY` оценена в CHF
|
||||||
|
- **WHEN** User впервые открывает вкладку «Концентрация»
|
||||||
|
- **THEN** в выборе RUB и USD, выбрана RUB, EUR и CHF в выборе нет
|
||||||
|
|
||||||
|
#### Scenario: Единственная валюта
|
||||||
|
- **GIVEN** все оценённые акции, облигации и фонды стоят в RUB
|
||||||
|
- **WHEN** User открывает вкладку «Концентрация»
|
||||||
|
- **THEN** выбора валюты нет, значения показаны в RUB
|
||||||
|
|
||||||
|
#### Scenario: Валюта только у позиции с нулевой стоимостью
|
||||||
|
- **GIVEN** акции стоят 100 RUB, а единственная позиция в USD — акция со стоимостью 0
|
||||||
|
- **WHEN** User открывает вкладку «Концентрация»
|
||||||
|
- **THEN** в списке только RUB, USD в нём нет
|
||||||
|
|
||||||
|
#### Scenario: Валюта только у отрицательной позиции
|
||||||
|
- **GIVEN** акции стоят 100 RUB, а единственная позиция в USD — акция со стоимостью -50
|
||||||
|
- **WHEN** User выбирает USD в разрезе «Акции»
|
||||||
|
- **THEN** показаны пустое состояние и сноска «Не учтено позиций: 1»
|
||||||
|
|
||||||
|
#### Scenario: Нет оценённых позиций
|
||||||
|
- **GIVEN** у всех позиций `STOCK`, `BOND` и `FUND` `currentValue` равен `null`
|
||||||
|
- **WHEN** User открывает вкладку «Концентрация»
|
||||||
|
- **THEN** выбора валюты нет, разрез показывает пустое состояние без валюты и сноску с числом таких позиций
|
||||||
|
|
||||||
|
### Requirement: Независимый выбор валюты вкладки
|
||||||
|
Выбор валюты вкладки «Концентрация» SHALL быть независим от выбора валюты в «Структуре портфеля»: смена валюты в одном месте SHALL NOT менять валюту в другом. Если выбранная валюта вкладки пропала из списка после обновления Portfolio, система SHALL выбрать первую валюту списка.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||||
|
|
||||||
|
#### Scenario: Структура портфеля не влияет
|
||||||
|
- **GIVEN** в «Структуре портфеля» выбрана USD, а на вкладке «Концентрация» выбрана RUB
|
||||||
|
- **WHEN** User меняет валюту в «Структуре портфеля» на RUB и обратно
|
||||||
|
- **THEN** выбранная на вкладке валюта остаётся RUB
|
||||||
|
|
||||||
|
#### Scenario: Валюта пропала после обновления
|
||||||
|
- **GIVEN** на вкладке выбрана USD
|
||||||
|
- **WHEN** обновлённый Portfolio не содержит позиций `STOCK`, `BOND` и `FUND` в USD
|
||||||
|
- **THEN** выбрана первая валюта обновлённого списка
|
||||||
|
|
||||||
|
### Requirement: Топ-5 и строка «Прочее»
|
||||||
|
Система SHALL упорядочивать элементы разреза по сумме по убыванию. Если элементов не больше шести, каждый SHALL показываться строкой с названием, суммой и долей. Если семь и больше, SHALL показываться пять первых строк и строка «Прочее» с суммой, долей и числом остальных элементов («2 элемента», «5 элементов»).
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)
|
||||||
|
|
||||||
|
#### Scenario: Семь акций
|
||||||
|
- **GIVEN** в RUB есть семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** показаны пять строк с суммами 400, 250, 150, 100, 50 и строка «Прочее» 50 ₽ (2 элемента) с долей 5 %
|
||||||
|
|
||||||
|
#### Scenario: Ровно шесть элементов
|
||||||
|
- **GIVEN** в RUB есть шесть акций с суммами 300, 250, 200, 150, 60 и 40
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** показаны шесть строк с долями 30 %, 25 %, 20 %, 15 %, 6 % и 4 %, строки «Прочее» нет
|
||||||
|
|
||||||
|
#### Scenario: Ровно пять элементов
|
||||||
|
- **GIVEN** в RUB есть пять акций с суммами 300, 250, 200, 150 и 100
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** показаны пять строк, строки «Прочее» нет
|
||||||
|
|
||||||
|
#### Scenario: Меньше пяти элементов
|
||||||
|
- **GIVEN** в RUB есть три акции
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** показаны три строки, строки «Прочее» нет
|
||||||
|
|
||||||
|
### Requirement: Раскрытие строки «Прочее»
|
||||||
|
По нажатию на «Прочее», в том числе с клавиатуры, система SHALL раскрывать полный список остальных элементов в порядке ранжирования с суммой и долей; повторное нажатие сворачивает его. Раскрытие SHALL NOT менять пять первых строк и диаграмму. При показе разреза и смене валюты «Прочее» SHALL быть свёрнуто.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)
|
||||||
|
|
||||||
|
#### Scenario: Раскрытие «Прочее»
|
||||||
|
- **GIVEN** показан разрез «Акции» из семи акций (400, 250, 150, 100, 50, 30, 20), «Прочее» свёрнуто
|
||||||
|
- **WHEN** User нажимает на строку «Прочее»
|
||||||
|
- **THEN** видны две акции с суммами 30 ₽ и 20 ₽ и долями 3 % и 2 %, пять первых строк прежние
|
||||||
|
|
||||||
|
#### Scenario: Повторное нажатие
|
||||||
|
- **GIVEN** «Прочее» раскрыто
|
||||||
|
- **WHEN** User нажимает на него ещё раз
|
||||||
|
- **THEN** список остальных элементов скрыт
|
||||||
|
|
||||||
|
#### Scenario: Смена валюты сворачивает «Прочее»
|
||||||
|
- **GIVEN** «Прочее» раскрыто в RUB
|
||||||
|
- **WHEN** User выбирает USD
|
||||||
|
- **THEN** «Прочее» свёрнуто
|
||||||
|
|
||||||
|
### Requirement: Порядок элементов с равной суммой
|
||||||
|
Элементы разреза с равной суммой SHALL упорядочиваться по названию по возрастанию без учёта регистра: сначала названия, начинающиеся с цифры, затем с латинской буквы, затем с кириллической; внутри группы — по алфавиту, при совпадении названия — по порядку первой позиции элемента в Portfolio.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||||
|
|
||||||
|
#### Scenario: Равные суммы на границе пятой строки
|
||||||
|
- **GIVEN** в RUB семь акций: 400, 300, 200, 150, «Альфа» 100, «Бета» 100 и 50
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** «Альфа» пятая строка, а «Бета» и акция на 50 входят в «Прочее»
|
||||||
|
|
||||||
|
#### Scenario: Цифры, латиница и кириллица при равных суммах
|
||||||
|
- **GIVEN** в RUB три акции с равной суммой 100 и названиями «Сбер», «VK» и «X5»
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** порядок строк: «VK», «X5», «Сбер»
|
||||||
|
|
||||||
|
### Requirement: Расчёт долей
|
||||||
|
Точная доля элемента — сумма его учитываемых позиций, делённая на сумму всех учитываемых позиций разреза в выбранной валюте; Cash Balance в расчёте не участвует. Точные доли элементов SHALL давать в сумме ровно 100 %, точная доля «Прочее» — равняться сумме точных долей остальных элементов, суммы — быть без округления.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||||
|
|
||||||
|
#### Scenario: Доля без денежных остатков
|
||||||
|
- **GIVEN** акции на 600 RUB и 400 RUB и Cash Balance 1 000 RUB
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** доли 60 % и 40 %
|
||||||
|
|
||||||
|
#### Scenario: Доля «Прочее»
|
||||||
|
- **GIVEN** семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||||
|
- **WHEN** User открывает разрез «Акции» и раскрывает «Прочее»
|
||||||
|
- **THEN** доля «Прочее» 5 % равна сумме долей 3 % и 2 %
|
||||||
|
|
||||||
|
### Requirement: Показ долей
|
||||||
|
Доля SHALL показываться, как в карточке позиции: округление до десятых в русской локали, середина — вверх (6,25 % — «6,3 %»), без «,0» («40 %», «66,7 %»). Положительная доля меньше 0,1 % показывается как «< 0,1 %». Суммарная доля топ-5 — в том же формате; сумма показанных долей может не быть 100 %.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079), [3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||||
|
|
||||||
|
#### Scenario: Три равные доли
|
||||||
|
- **GIVEN** три акции «А», «Б» и «В» по 100 RUB
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** каждая доля показана как «33,3 %»
|
||||||
|
|
||||||
|
#### Scenario: Округление, сумма показанных долей не равна 100 %
|
||||||
|
- **GIVEN** две акции на 1 RUB и 15 RUB, точные доли 6,25 % и 93,75 %
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** доли показаны как «93,8 %» и «6,3 %», хотя в сумме дают 100,1 %
|
||||||
|
|
||||||
|
#### Scenario: Очень малая доля
|
||||||
|
- **GIVEN** акции на 100 000 000 RUB и 1 RUB
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** доли показаны как «100 %» и «< 0,1 %», сумма второй акции остаётся 1 ₽
|
||||||
|
|
||||||
|
### Requirement: Суммарная доля топ-5
|
||||||
|
Система SHALL показывать метрику «доля топ-5» — сумму точных долей пяти первых по сумме элементов разреза, в формате требования «Показ долей» — только когда элементов шесть и более. При пяти и менее элементах метрика SHALL NOT показываться.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||||
|
|
||||||
|
#### Scenario: Семь элементов
|
||||||
|
- **GIVEN** в RUB семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** доли строк 40 %, 25 %, 15 %, 10 %, 5 % и «Прочее» 5 %, доля топ-5 равна 95 %
|
||||||
|
|
||||||
|
#### Scenario: Ровно шесть элементов
|
||||||
|
- **GIVEN** в RUB шесть акций с суммами 300, 250, 200, 150, 60 и 40
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** доля топ-5 равна 96 %, а шестая строка показана с долей 4 %
|
||||||
|
|
||||||
|
#### Scenario: Ровно пять элементов
|
||||||
|
- **GIVEN** в RUB пять акций с суммами 300, 250, 200, 150 и 100
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** метрика «доля топ-5» не показывается
|
||||||
|
|
||||||
|
#### Scenario: Один элемент
|
||||||
|
- **GIVEN** в RUB одна акция
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** метрика «доля топ-5» не показывается
|
||||||
|
|
||||||
|
### Requirement: Группы «Сектор не указан» и «Эмитент не указан»
|
||||||
|
Система SHALL объединять учитываемые позиции с `sector: null` («Секторы») в элемент «Сектор не указан», а с `issuer: null` («Эмитенты») — в элемент «Эмитент не указан». Эти элементы SHALL ранжироваться и попадать в «Прочее» наравне с остальными, а их название SHALL быть видно в списке и диаграмме. Сектор и эмитент SHALL NOT выводиться из названия.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||||
|
|
||||||
|
#### Scenario: Позиции без эмитента в топе
|
||||||
|
- **GIVEN** в RUB акции эмитента «Сбер» на 300, эмитента «Газпром» на 300 и без эмитента на 200 и 100
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты» в RUB
|
||||||
|
- **THEN** по убыванию: «Газпром» 300, «Сбер» 300, «Эмитент не указан» 300 (две позиции), группа ранжируется наравне с другими
|
||||||
|
|
||||||
|
#### Scenario: Группа без сектора в «Прочее»
|
||||||
|
- **GIVEN** у шести секторов суммы 600, 500, 400, 300, 200 и 100, у позиций без сектора сумма 50
|
||||||
|
- **WHEN** User раскрывает «Прочее» в разрезе «Секторы»
|
||||||
|
- **THEN** «Прочее» содержит шестой сектор 100 ₽ и «Сектор не указан» 50 ₽
|
||||||
|
|
||||||
|
### Requirement: Подписи секторов
|
||||||
|
Система SHALL показывать известное значение `sector` русской подписью по требованию «Русские подписи известных секторов», неизвестное — как получено, без перевода и пояснений, в списке и текстовом эквиваленте диаграммы. Система SHALL NOT придумывать подписи сверх таблицы и собственную классификацию и SHALL NOT объединять позиции с разными `sector`.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||||
|
|
||||||
|
#### Scenario: Неизвестное значение сектора
|
||||||
|
- **GIVEN** облигация имеет `sector` `other`, которого нет в таблице подписей
|
||||||
|
- **WHEN** User открывает разрез «Секторы»
|
||||||
|
- **THEN** элемент назван `other`
|
||||||
|
|
||||||
|
### Requirement: Русские подписи известных секторов
|
||||||
|
Система SHALL показывать `sector` подписью по таблице.
|
||||||
|
|
||||||
|
| Значение | Подпись |
|
||||||
|
| --- | --- |
|
||||||
|
| `consumer` | Потребительский сектор |
|
||||||
|
| `energy` | Энергетика |
|
||||||
|
| `financial` | Финансы |
|
||||||
|
| `health_care` | Здравоохранение |
|
||||||
|
| `industrials` | Промышленность |
|
||||||
|
| `it` | Информационные технологии |
|
||||||
|
| `materials` | Материалы |
|
||||||
|
| `real_estate` | Недвижимость |
|
||||||
|
| `telecom` | Телекоммуникации |
|
||||||
|
| `utilities` | Коммунальные услуги |
|
||||||
|
| `government` | Государственный сектор |
|
||||||
|
|
||||||
|
|
||||||
|
#### Scenario: Известный сектор
|
||||||
|
- **GIVEN** позиция имеет `sector` `it`
|
||||||
|
- **WHEN** User открывает разрез «Секторы»
|
||||||
|
- **THEN** элемент назван «Информационные технологии»
|
||||||
|
|
||||||
|
#### Scenario: Государственные облигации
|
||||||
|
- **GIVEN** облигация имеет `sector` `government`
|
||||||
|
- **WHEN** User открывает разрез «Секторы»
|
||||||
|
- **THEN** элемент назван «Государственный сектор»
|
||||||
|
|
||||||
|
### Requirement: Диаграмма распределения разреза
|
||||||
|
Система SHALL показывать диаграмму разреза с теми же элементами, в том же порядке и с теми же долями, что в списке: для разреза не более чем из шести элементов — сегмент на каждый элемент, для разреза из семи и более — пять сегментов и один сегмент «Прочее», одинаково для свёрнутого и раскрытого списка. Для пустого разреза диаграмма SHALL NOT показываться. Не учтённые позиции SHALL NOT входить в диаграмму.
|
||||||
|
|
||||||
|
#### Scenario: Семь элементов
|
||||||
|
- **GIVEN** семь акций с суммами 400, 250, 150, 100, 50, 30 и 20 RUB
|
||||||
|
- **WHEN** User открывает разрез «Акции» и раскрывает «Прочее»
|
||||||
|
- **THEN** диаграмма состоит из шести сегментов с долями 40 %, 25 %, 15 %, 10 %, 5 %, 5 % в порядке списка, как и при свёрнутом «Прочее»
|
||||||
|
|
||||||
|
#### Scenario: Ровно шесть элементов
|
||||||
|
- **GIVEN** шесть акций с суммами 300, 250, 200, 150, 60 и 40 RUB
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** диаграмма состоит из шести сегментов с долями 30 %, 25 %, 20 %, 15 %, 6 %, 4 %
|
||||||
|
|
||||||
|
### Requirement: Центр диаграммы
|
||||||
|
Система SHALL показывать в центре диаграммы сумму разреза — точную сумму учитываемых позиций в выбранной валюте, а над ней, когда показывается метрика «доля топ-5», подпись «Топ-5 · N %», где N — доля в формате «Показ долей». Без метрики показывается только сумма. Эти значения SHALL входить в текстовый эквивалент диаграммы.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||||
|
|
||||||
|
#### Scenario: Центр при семи акциях
|
||||||
|
- **GIVEN** в RUB семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** в центре «Топ-5 · 95 %» и «1 000 ₽»
|
||||||
|
|
||||||
|
#### Scenario: Центр при пяти акциях
|
||||||
|
- **GIVEN** в RUB пять акций с суммами 300, 250, 200, 150 и 100
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** в центре только «1 000 ₽»
|
||||||
|
|
||||||
|
### Requirement: Строки без переходов
|
||||||
|
Строка списка в любом разрезе SHALL NOT вести на другую страницу; единственное действие строки — раскрытие и сворачивание «Прочее». Строки разреза «Эмитенты» SHALL NOT содержать логотипов.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||||
|
|
||||||
|
#### Scenario: Нажатие на строку акции
|
||||||
|
- **WHEN** User нажимает на строку акции в разрезе «Акции»
|
||||||
|
- **THEN** страница не меняется
|
||||||
|
|
||||||
|
#### Scenario: Строка эмитента
|
||||||
|
- **WHEN** User открывает разрез «Эмитенты»
|
||||||
|
- **THEN** строки показаны без логотипов
|
||||||
|
|
||||||
|
### Requirement: Доступность диаграммы без цвета
|
||||||
|
Информация диаграммы SHALL NOT передаваться только цветом: каждый сегмент SHALL быть сопоставим со строкой списка по названию и доле, доступным как текст. Диаграмма SHALL иметь текстовый эквивалент с названиями, суммами и долями сегментов в порядке списка, доступный вспомогательным технологиям.
|
||||||
|
|
||||||
|
#### Scenario: Сегменты различимы без цвета
|
||||||
|
- **GIVEN** показан разрез из шести строк
|
||||||
|
- **WHEN** User читает диаграмму вспомогательной технологией
|
||||||
|
- **THEN** доступны название, сумма и доля каждого сегмента в порядке списка, сопоставление со строкой не требует различения цветов
|
||||||
|
|
||||||
|
### Requirement: Пустое состояние разреза
|
||||||
|
Если выбранный разрез не содержит учитываемых элементов, система SHALL показывать пустое состояние с названием разреза и выбранной валютой (если валюты доступны) вместо списка, суммарной доли и диаграммы. Выбор валюты, если он показывается, и сноска при N больше нуля SHALL оставаться доступными.
|
||||||
|
|
||||||
|
#### Scenario: Нет облигаций в выбранной валюте
|
||||||
|
- **GIVEN** есть акции в RUB и облигации только в USD
|
||||||
|
- **WHEN** User открывает разрез «Облигации» в RUB
|
||||||
|
- **THEN** показано пустое состояние, выбор валюты доступен (валют две)
|
||||||
|
|
||||||
|
#### Scenario: Все позиции разреза отрицательные
|
||||||
|
- **GIVEN** в RUB есть только одна акция со стоимостью -50
|
||||||
|
- **WHEN** User открывает разрез «Акции» в RUB
|
||||||
|
- **THEN** показано пустое состояние и сноска «Не учтено позиций: 1»
|
||||||
|
|
||||||
|
### Requirement: Предупреждение о неполных данных
|
||||||
|
Если `meta` обозначает неполноту по «Результат личного обновления остаётся наблюдаемым» из `tbank-data-sync`, вкладка SHALL показывать предупреждение «Данные неполные» над разрезом, считая разрезы по доступным данным. Страница SHALL NOT дублировать его; позиции без оценки его не вызывают.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||||
|
|
||||||
|
#### Scenario: Неполный Portfolio
|
||||||
|
- **GIVEN** `meta` обозначает неполноту, акции стоят 600 и 400 RUB
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** показаны предупреждение «Данные неполные» и доли 60 % и 40 %
|
||||||
|
|
||||||
|
#### Scenario: Предупреждение не дублируется
|
||||||
|
- **GIVEN** `meta` обозначает неполноту
|
||||||
|
- **WHEN** User открывает вкладку «Концентрация»
|
||||||
|
- **THEN** на странице одно сообщение о неполноте — «Данные неполные» на вкладке
|
||||||
|
|
||||||
|
#### Scenario: Полный Portfolio с неоценённой позицией
|
||||||
|
- **GIVEN** Portfolio полный, одна акция без `currentValue`
|
||||||
|
- **WHEN** User открывает разрез «Акции»
|
||||||
|
- **THEN** предупреждения «Данные неполные» нет, есть сноска «Не учтено позиций: 1»
|
||||||
|
|
||||||
|
### Requirement: Состояния Portfolio на вкладке «Концентрация»
|
||||||
|
Загрузка, ошибка, устаревание и неполнота Portfolio на вкладке «Концентрация» SHALL соответствовать требованию «Состояния и ошибки Portfolio на клиенте» из `portfolio-view`, как на вкладке «Позиции». Отсутствие сектора или эмитента у позиций SHALL NOT скрывать вкладку и менять успешность и свежесть Portfolio.
|
||||||
|
|
||||||
|
#### Scenario: Устаревший Portfolio
|
||||||
|
- **GIVEN** клиент получает Portfolio с `isStale: true`
|
||||||
|
- **WHEN** User открывает вкладку «Концентрация»
|
||||||
|
- **THEN** разрезы рассчитаны по этому Portfolio, устаревание обозначено так же, как на вкладке «Позиции»
|
||||||
|
|
||||||
|
#### Scenario: Сектор и эмитент отсутствуют
|
||||||
|
- **GIVEN** у всех позиций `sector: null` и `issuer: null`, Portfolio успешен
|
||||||
|
- **WHEN** User открывает разрезы «Секторы» и «Эмитенты»
|
||||||
|
- **THEN** каждый разрез содержит один элемент «Сектор не указан» или «Эмитент не указан» с долей 100 %
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Нормализованный состав Portfolio
|
||||||
|
Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Portfolio Position с типом `CURRENCY` MUST означать позицию по торгуемому валютному Instrument и MUST NOT смешиваться с Cash Balance. Порядок Portfolio Position и Cash Balance MUST соответствовать upstream-порядку; система MUST NOT сортировать или объединять Portfolio Position с одинаковым FIGI. Виртуальные позиции и заблокированные денежные остатки MUST NOT входить в Portfolio.
|
||||||
|
|
||||||
|
Каждая Portfolio Position MUST содержать нормализованный тип `STOCK`, `BOND`, `FUND`, `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` либо `OTHER`, строковое `quantity`, nullable `currentValue`, nullable `averagePurchasePrice`, nullable `absoluteReturn`, nullable `isin`, nullable `ticker` и nullable `logoName` — имя файла логотипа бренда инструмента, nullable `sector` — сектор инструмента и nullable `issuer` — эмитент позиции. Имя файла логотипа MUST заполняться из metadata инструмента, когда upstream предоставляет бренд с непустым именем файла логотипа; во всех остальных случаях оно MUST быть `null`. Логотип является presentation metadata: он не влияет на количество, стоимость, агрегаты, успешность или свежесть Portfolio Snapshot. Поле `sector` позиции типа `STOCK`, `BOND` или `FUND` MUST содержать значение сектора из metadata инструмента в том виде, как его определяет T-Bank, без перекодирования и собственной классификации, с обрезанными пробелами в начале и конце; значение из одних пробелов считается отсутствующим. Во всех остальных случаях, включая остальные типы позиций, оно MUST быть `null`. Поле `issuer` позиции типа `STOCK`, `BOND` или `FUND`, включая фонд, MUST быть объектом с непустыми строками `id` — идентификатор бренда актива инструмента — и `name` — название этого бренда (не название компании), когда T-Bank предоставляет для инструмента бренд актива с идентификатором и названием, непустыми после обрезки пробелов в начале и конце; значение из одних пробелов считается отсутствующим. Во всех остальных случаях, включая остальные типы позиций, оно MUST быть `null`. Система MUST NOT определять эмитента по совпадению названия, тикера или ISIN. Сектор и эмитент не влияют на количество, стоимость, агрегаты, успешность или свежесть Portfolio Snapshot: их отсутствие либо недоступность MUST NOT делать Snapshot неуспешным или устаревшим и MUST NOT скрывать позицию или её финансовые значения, кроме отказа token по требованию «Свежесть и fallback полного Portfolio Snapshot». Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения.
|
||||||
|
|
||||||
|
#### Scenario: Позиция с неизвестным upstream-типом
|
||||||
|
- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента
|
||||||
|
- **THEN** система возвращает её в исходном порядке с типом `OTHER`
|
||||||
|
|
||||||
|
#### Scenario: Позиция валютного инструмента не является денежным остатком
|
||||||
|
- **WHEN** upstream возвращает ненулевую Portfolio Position валютного инструмента и ненулевой Cash Balance той же валюты
|
||||||
|
- **THEN** система возвращает Portfolio Position с типом `CURRENCY` отдельно от Cash Balance и не объединяет их между собой
|
||||||
|
|
||||||
|
#### Scenario: Позиция без metadata инструмента
|
||||||
|
- **WHEN** lookup metadata по FIGI не дал ticker или name либо временно недоступен
|
||||||
|
- **THEN** система возвращает позицию с FIGI как fallback-идентификатором и всеми доступными финансовыми значениями
|
||||||
|
|
||||||
|
#### Scenario: Имя инструмента доступно
|
||||||
|
- **WHEN** metadata инструмента по FIGI содержит непустое name
|
||||||
|
- **THEN** система возвращает name как отображаемый идентификатор позиции вместо FIGI или ticker
|
||||||
|
|
||||||
|
#### Scenario: FIGI не разрешается, но доступен instrument UID
|
||||||
|
- **WHEN** FIGI позиции не даёт metadata, а её instrument UID возвращает непустое name
|
||||||
|
- **THEN** система возвращает name, полученное по instrument UID
|
||||||
|
|
||||||
|
#### Scenario: ISIN инструмента доступен
|
||||||
|
- **WHEN** metadata инструмента позиции содержит непустой `isin`
|
||||||
|
- **THEN** система возвращает его в поле `isin` позиции
|
||||||
|
|
||||||
|
#### Scenario: ISIN инструмента отсутствует
|
||||||
|
- **WHEN** у инструмента позиции нет ISIN либо metadata недоступна
|
||||||
|
- **THEN** система возвращает позицию с `isin: null`, не скрывая финансовые значения
|
||||||
|
|
||||||
|
#### Scenario: Тикер инструмента доступен
|
||||||
|
- **WHEN** metadata инструмента позиции содержит непустой `ticker`
|
||||||
|
- **THEN** система возвращает его в поле `ticker` позиции
|
||||||
|
|
||||||
|
#### Scenario: Тикер инструмента отсутствует
|
||||||
|
- **WHEN** у инструмента позиции нет ticker либо metadata недоступна
|
||||||
|
- **THEN** система возвращает позицию с `ticker: null`, не скрывая финансовые значения
|
||||||
|
|
||||||
|
#### Scenario: Имя файла логотипа бренда доступно
|
||||||
|
- **WHEN** metadata инструмента позиции содержит бренд с непустым именем файла логотипа
|
||||||
|
- **THEN** система возвращает его в поле `logoName` позиции
|
||||||
|
|
||||||
|
#### Scenario: Имя файла логотипа бренда отсутствует
|
||||||
|
- **WHEN** у инструмента позиции нет бренда, имя файла логотипа пустое либо metadata недоступна
|
||||||
|
- **THEN** система возвращает позицию с `logoName: null`, не скрывая финансовые значения
|
||||||
|
|
||||||
|
#### Scenario: Денежные остатки в одной валюте
|
||||||
|
- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты
|
||||||
|
- **THEN** система возвращает один Cash Balance с их точной суммой
|
||||||
|
|
||||||
|
#### Scenario: Штатный пустой Portfolio
|
||||||
|
- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков
|
||||||
|
- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency`
|
||||||
|
|
||||||
|
#### Scenario: Сектор инструмента доступен
|
||||||
|
- **WHEN** metadata инструмента позиции содержит сектор `energy`
|
||||||
|
- **THEN** система возвращает в поле `sector` позиции значение `energy` без изменений
|
||||||
|
|
||||||
|
#### Scenario: Сектор инструмента отсутствует
|
||||||
|
- **WHEN** у инструмента позиции нет сектора, сектор пустой либо metadata недоступна
|
||||||
|
- **THEN** система возвращает позицию с `sector: null`, не скрывая финансовые значения
|
||||||
|
|
||||||
|
#### Scenario: Эмитент позиции доступен
|
||||||
|
- **WHEN** T-Bank предоставляет для инструмента позиции бренд актива с идентификатором `brand-1` и названием «Сбер»
|
||||||
|
- **THEN** система возвращает в поле `issuer` позиции идентификатор `brand-1` и название «Сбер»
|
||||||
|
|
||||||
|
#### Scenario: Название эмитента — название бренда
|
||||||
|
- **WHEN** T-Bank предоставляет для инструмента бренд актива с названием «Сбер» и компанией «ПАО Сбербанк»
|
||||||
|
- **THEN** система возвращает в `issuer.name` «Сбер»
|
||||||
|
|
||||||
|
#### Scenario: Эмитент у фонда
|
||||||
|
- **WHEN** T-Bank предоставляет для инструмента позиции `FUND` бренд актива с идентификатором `brand-2` и названием «Фонд»
|
||||||
|
- **THEN** система возвращает в `issuer` позиции идентификатор `brand-2` и название «Фонд»
|
||||||
|
|
||||||
|
#### Scenario: Пробелы по краям обрезаются
|
||||||
|
- **WHEN** T-Bank возвращает сектор " energy " и название бренда " Сбер "
|
||||||
|
- **THEN** система возвращает `sector` `energy` и `issuer.name` «Сбер»
|
||||||
|
|
||||||
|
#### Scenario: Значение из одних пробелов
|
||||||
|
- **WHEN** T-Bank возвращает сектор " " либо название бренда " "
|
||||||
|
- **THEN** система возвращает `sector: null` либо `issuer: null` соответственно
|
||||||
|
|
||||||
|
#### Scenario: Остальные типы позиций
|
||||||
|
- **WHEN** T-Bank предоставляет сектор и бренд актива для инструмента позиции типа `FUTURE`
|
||||||
|
- **THEN** система возвращает у этой позиции `sector: null` и `issuer: null`
|
||||||
|
|
||||||
|
#### Scenario: Эмитент позиции отсутствует
|
||||||
|
- **WHEN** у инструмента позиции нет бренда актива, идентификатор либо название бренда пустые или данные бренда недоступны
|
||||||
|
- **THEN** система возвращает позицию с `issuer: null`, не скрывая финансовые значения
|
||||||
|
|
||||||
|
#### Scenario: Эмитент не определяется по названию
|
||||||
|
- **WHEN** две позиции имеют одинаковое название, но разные идентификаторы бренда актива
|
||||||
|
- **THEN** система возвращает в `issuer` позиций разные идентификаторы бренда и не объединяет их
|
||||||
|
|
||||||
|
#### Scenario: Недоступность сектора и эмитента не нарушает Snapshot
|
||||||
|
- **GIVEN** сектор и бренд актива позиции ещё ни разу не получены
|
||||||
|
- **WHEN** получение сектора и бренда актива завершается ошибкой, а количества и цены позиции получены
|
||||||
|
- **THEN** система сохраняет Snapshot с `sector: null` и `issuer: null` у затронутых позиций; их `currentValue`, `totalsByCurrency`, `lastSuccessfulAt` и `isStale` такие же, как при доступных сектора и бренде
|
||||||
|
|
||||||
|
### Requirement: Свежесть и fallback полного Portfolio Snapshot
|
||||||
|
Система MUST хранить последний полный успешно полученный Portfolio Snapshot с принадлежностью User, экземпляру подключения и Brokerage Account. Обычный GET MUST возвращать `{ data, meta }`, где `data` является сохранённым Snapshot, а `meta` соответствует наблюдаемому состоянию resource по `tbank-data-sync`, читать его без ожидания T-Bank и MUST NOT запускать обновление личного Portfolio, даже если Snapshot отсутствует или старше 60 минут; обновлением владеет часовой обход. Portfolio source MUST считаться свежим в течение 60 минут после успешного получения. Ошибка обхода MUST сохранять прежний полный Snapshot и его время без лимита fallback. Если состав получен полностью, но отдельная позиция не имеет цены, система MUST сохранить новый состав с неполной оценкой; metadata MUST NOT определять успешность или свежесть финансового Snapshot, кроме отказа token при получении сектора или бренда актива позиции: он MUST обрабатываться так же, как отказ token при обходе Portfolio (исключение, источник — [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)). Сохранённая доступная identity позиции MAY оставаться доступной при недоступности online-справочника.
|
||||||
|
|
||||||
|
Система MUST делать прежние личные datasets недоступными после успешной замены или удаления подключения. Поздняя работа прежнего подключения MUST NOT публиковаться в новом. Самостоятельные справочные сведения и результаты lookup MUST запрашиваться онлайн, а не сохраняться как долговременный справочник; ошибка lookup не подтверждает отсутствие инструмента. Исключение: сектор и бренд актива позиции MUST храниться как последнее известное значение и обновляться по требованию «Обновление сектора и эмитента» ([#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)).
|
||||||
|
|
||||||
|
#### Scenario: Свежий Snapshot
|
||||||
|
- **WHEN** User повторно запрашивает тот же Portfolio в течение 60 минут после успешного получения
|
||||||
|
- **THEN** система возвращает свежий Snapshot без нового обращения к T-Bank
|
||||||
|
|
||||||
|
#### Scenario: Медленный lookup инструмента
|
||||||
|
- **WHEN** сведения об инструменте ещё запрашиваются онлайн
|
||||||
|
- **THEN** система не задерживает локальный ответ Portfolio ради lookup и показывает сохранённую доступную identity
|
||||||
|
|
||||||
|
#### Scenario: Fallback после временной upstream-ошибки
|
||||||
|
- **WHEN** часовой обход Portfolio неуспешен из-за временной недоступности T-Bank, а в текущей границе подключения есть полный успешный Snapshot
|
||||||
|
- **THEN** система отвечает `200` этим Snapshot с `isStale: true` и исходным `lastSuccessfulAt`
|
||||||
|
|
||||||
|
#### Scenario: Недействительный token не маскируется fallback-данными
|
||||||
|
- **WHEN** T-Bank отклоняет token как неаутентифицированный или без прав при часовом обходе Portfolio
|
||||||
|
- **THEN** система не возвращает fallback Snapshot и отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN`
|
||||||
|
|
||||||
|
#### Scenario: Неполная оценка при новом составе
|
||||||
|
- **WHEN** успешно получен полный состав позиций, но у одной позиции нет вычислимой цены
|
||||||
|
- **THEN** новый состав становится текущим Portfolio Snapshot с неизвестной стоимостью позиции без подстановки нуля
|
||||||
|
|
||||||
|
#### Scenario: Сектор и бренд читаются из хранимого значения
|
||||||
|
- **GIVEN** сектор позиции получен 10 часов назад
|
||||||
|
- **WHEN** User запрашивает Portfolio
|
||||||
|
- **THEN** система возвращает хранимый `sector` без обращения к T-Bank за сектором
|
||||||
|
|
||||||
|
#### Scenario: Отказ token при получении бренда
|
||||||
|
- **WHEN** T-Bank отклоняет token как неаутентифицированный или без прав при получении сектора или бренда актива позиции
|
||||||
|
- **THEN** система не возвращает fallback Snapshot и отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN`, как при отказе token в обходе Portfolio
|
||||||
|
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Обновление сектора и эмитента
|
||||||
|
Система SHALL обновлять сектор и бренд актива позиции при первом обходе по `hourly-investment-sync`, начавшемся не ранее чем через 24 часа после последнего успешного получения; в остальных обходах значение не меняется. Пока значение не получено, получение повторяется при каждом обходе.
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||||
|
|
||||||
|
#### Scenario: Обход раньше 24 часов
|
||||||
|
- **GIVEN** бренд позиции получен в понедельник в 09:00, а затем T-Bank сменил его название
|
||||||
|
- **WHEN** обход начинается во вторник в 08:59, то есть через 23 ч 59 мин
|
||||||
|
- **THEN** `issuer.name` остаётся прежним
|
||||||
|
|
||||||
|
#### Scenario: Обход через 24 часа
|
||||||
|
- **GIVEN** бренд позиции получен в понедельник в 09:00, а затем T-Bank сменил его название
|
||||||
|
- **WHEN** обход начинается во вторник в 09:00
|
||||||
|
- **THEN** `issuer.name` новое
|
||||||
|
|
||||||
|
### Requirement: Результат получения сектора и эмитента
|
||||||
|
Результат получения SHALL определять поле позиции и отсчёт 24 часов по таблице; совпадает ровно одна строка.
|
||||||
|
|
||||||
|
| Исход получения | Поле | Отсчёт 24 часов |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Получено значение | Новое значение | Начинается заново |
|
||||||
|
| Ответ «не найдено» или значения нет | `null` | Начинается заново |
|
||||||
|
| Ответ о другом инструменте или активе | Прежнее значение или `null` | Не меняется; получение неуспешно |
|
||||||
|
| Иная ошибка, кроме отказа token | Прежнее значение или `null` | Не меняется; получение неуспешно |
|
||||||
|
|
||||||
|
Неуспешное получение SHALL NOT мешать обновлению остальных данных Portfolio; отказ token — по «Свежесть и fallback полного Portfolio Snapshot».
|
||||||
|
|
||||||
|
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||||
|
|
||||||
|
#### Scenario: Ответ без значения заменяет известное значение
|
||||||
|
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
|
||||||
|
- **WHEN** T-Bank отвечает, что сектора у инструмента нет
|
||||||
|
- **THEN** `sector: null`
|
||||||
|
|
||||||
|
#### Scenario: «Не найдено» перезапускает отсчёт
|
||||||
|
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли, и обход получает от T-Bank «не найдено»
|
||||||
|
- **WHEN** следующий обход начинается через час, а T-Bank уже возвращает сектор `financial`
|
||||||
|
- **THEN** `sector` остаётся `null`; значение `financial` появляется при первом обходе, начавшемся не ранее чем через 24 часа после ответа «не найдено»
|
||||||
|
|
||||||
|
#### Scenario: Ответ о другом инструменте
|
||||||
|
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
|
||||||
|
- **WHEN** T-Bank возвращает данные другого инструмента
|
||||||
|
- **THEN** `sector` остаётся `energy`, а следующий обход повторяет получение
|
||||||
|
|
||||||
|
#### Scenario: Недоступность T-Bank сохраняет известное значение
|
||||||
|
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
|
||||||
|
- **WHEN** получение сектора завершается недоступностью T-Bank, а цены и количества получены
|
||||||
|
- **THEN** позиция возвращается с `sector` `energy`, остальные данные Portfolio обновлены, `lastSuccessfulAt` новый
|
||||||
|
|
||||||
|
#### Scenario: Значение ещё не известно
|
||||||
|
- **GIVEN** новая позиция, сектор которой ни разу не получен успешно
|
||||||
|
- **WHEN** получение сектора неуспешно при одном обходе и успешно при следующем
|
||||||
|
- **THEN** после первого обхода `sector: null`, после следующего значение сектора
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
Issue-координатор: [WealthLedger/main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48). В каждом affected repo ветка `feature/48-account-top-positions` создаётся от актуального `develop`; PR открывается в `develop`. Тело PR: Summary, Linked Issue `WealthLedger/main#48`, Changes, Verification, ссылки на связанные PR других repo — по `.ai/conventions/engineering-workflow.md` («Analysis → Branch», «Validation → PR»). Обозначения **PC**, **PVΔ**: PC — `portfolio-concentration`, PVΔ — delta `portfolio-view`.
|
||||||
|
|
||||||
|
## 1. Backend: хранение, классификация, DTO, OpenAPI
|
||||||
|
|
||||||
|
- [x] 1.1 [backend] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
|
||||||
|
- **Covers:** N/A — организационный шаг cross-repo delivery
|
||||||
|
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch», «Branch → Implementation»
|
||||||
|
- **Verification:** `git -C backend branch --show-current` выводит `feature/48-account-top-positions`; `git log origin/develop..HEAD` пуст до первой задачи
|
||||||
|
- **Depends on:** none
|
||||||
|
|
||||||
|
- [x] 1.2 [backend] Migration: аддитивная модель `TbankInstrumentClassification` и миграция `CREATE TABLE`
|
||||||
|
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
|
||||||
|
- **Source:** design.md «Data / Storage Changes», B1; «Migration / Rollout / Rollback»
|
||||||
|
- **Verification:** `npx prisma validate`; `npx prisma generate`; `npm run typecheck:app`; миграция применена к disposable копии БД с существующими Snapshot, прежние таблицы и строки не изменились; `DROP TABLE "TbankInstrumentClassification"` на копии возвращает её к исходной схеме
|
||||||
|
- **Depends on:** 1.1
|
||||||
|
|
||||||
|
- [x] 1.3 [backend] Implementation: `TbankClient.getEtf`, `getAssetBrand` и opt-in NOT_FOUND для новых вызовов классификации
|
||||||
|
- **Covers:** `portfolio-view` — `Результат получения сектора и эмитента`
|
||||||
|
- **Source:** design.md B4 «Новые методы», «NOT_FOUND»; analysis/data-mapping.md «Источники-кандидаты в T-Bank»
|
||||||
|
- **Verification:** `npm test -- src/integrations/tbank/tbank.client.spec.ts`: NOT_FOUND даёт `NotFoundException` для `bondBy`/`etfBy`/`getAssetBy` только при opt-in; прежние `getBond`/`getAssetDescription` по-прежнему дают Unavailable; `PERMISSION_DENIED` даёт `InvalidToken`
|
||||||
|
- **Depends on:** 1.1
|
||||||
|
|
||||||
|
- [x] 1.4 [backend] Implementation: `PositionClassificationService` — правило 24 ч, четыре исхода по каждому полю, UID→FIGI после NOT_FOUND, дедупликация по инструменту и `asset_uid`
|
||||||
|
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-view` — `Результат получения сектора и эмитента`; `portfolio-view` — `Нормализованный состав Portfolio`
|
||||||
|
- **Source:** design.md B3, B4, B5; analysis/failure-model.md «Классификация отказов по исходам PVΔ «Результат получения сектора и эмитента»», F02–F12
|
||||||
|
- **Verification:** `npm test -- src/modules/investments/portfolio`: границы 23:59 и 24:00 от `walkStartedAt`; при отсутствии значения получение идёт в каждом обходе; 4 исхода × {sector, brand}; mismatch не меняет поле; повтор по FIGI после NOT_FOUND по UID; один `GetAssetBy` на общий `asset_uid`; используется сохранённый `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет; типы кроме STOCK/BOND/FUND не запрашиваются
|
||||||
|
- **Depends on:** 1.2, 1.3
|
||||||
|
|
||||||
|
- [x] 1.5 [backend] Implementation: шаг классификации в `PortfolioService.queueSnapshot` до publish; `walkStartedAt` из `TbankHourlyScheduler`; mapper пишет `sector`/`issuer`
|
||||||
|
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-view` — `Нормализованный состав Portfolio`
|
||||||
|
- **Source:** design.md B2, B3; analysis/failure-model.md F01, F08
|
||||||
|
- **Verification:** `npm test -- src/modules/investments/portfolio/application/portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован, вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован, `lastSuccessfulAt` и `isStale` не изменены, поля — прежние известные; ошибка GetPortfolio → публикации нет независимо от классификации
|
||||||
|
- **Depends on:** 1.4
|
||||||
|
|
||||||
|
- [x] 1.6 [backend] Implementation: DTO `sector` и `PortfolioIssuerDto` (nullable), нормализация старых Snapshot в `saved()`
|
||||||
|
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
|
||||||
|
- **Source:** design.md B6, B7; analysis/system-analysis.md «Семантическая delta `PortfolioPositionDto`», «Совместимость и миграция»
|
||||||
|
- **Verification:** `npm run test:e2e`: GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio` возвращают у каждой позиции `sector` и `issuer`; для payload без этих полей — `null`; после отказа token — `400 INVALID_TBANK_TOKEN`
|
||||||
|
- **Depends on:** 1.5
|
||||||
|
|
||||||
|
- [x] 1.7 [backend] Contract Parity: OpenAPI-artifact с новыми полями совпадает с target-delta
|
||||||
|
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
|
||||||
|
- **Source:** design.md «Contract Implementation»; analysis/system-analysis.md «Семантическая delta `PortfolioPositionDto`»
|
||||||
|
- **Verification:** `npm run openapi:generate` и `npm run test:openapi-contract` проходят; в корневом `openapi.json` у `PortfolioPositionDto` поля `sector` (string, nullable, required) и `issuer` (nullable, required, `id`/`name` обязательные строки); версия OpenAPI 3.0.x; запросы, маршруты и статусы не изменились (diff схемы с `frontend/src/shared/api/spec/openapi.json` содержит только эти добавления)
|
||||||
|
- **Depends on:** 1.6
|
||||||
|
|
||||||
|
- [x] 1.8 [backend] Verification: полный gate и PR в `develop` со ссылкой на WealthLedger/main#48
|
||||||
|
- **Covers:** N/A — финальный gate repo
|
||||||
|
- **Source:** backend `.ai/conventions/engineering.md` «Проверки и завершение»; `.ai/conventions/engineering-workflow.md` «Validation → PR»
|
||||||
|
- **Verification:** `npm run verify` зелёный на финальной ревизии; PR открыт, в теле `WealthLedger/main#48` и OpenAPI-artifact приложен как вход для frontend
|
||||||
|
- **Depends on:** 1.7
|
||||||
|
|
||||||
|
## 2. Frontend: контракт, модель, UI
|
||||||
|
|
||||||
|
- [x] 2.1 [frontend] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
|
||||||
|
- **Covers:** N/A — организационный шаг cross-repo delivery
|
||||||
|
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch»
|
||||||
|
- **Verification:** `git -C frontend branch --show-current` выводит `feature/48-account-top-positions`
|
||||||
|
- **Depends on:** none
|
||||||
|
|
||||||
|
- [x] 2.2 [frontend] Implementation: предоставленный backend snapshot в `src/shared/api/spec/openapi.json` и generated `schema.d.ts`
|
||||||
|
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
|
||||||
|
- **Source:** design.md B6; frontend `.ai/conventions/data.md` «Типы и contract»
|
||||||
|
- **Verification:** `npm run openapi:generate`; `npm run openapi:check` без diff; `npm run typecheck`; generated `paths` содержит `sector` и `issuer` у позиции
|
||||||
|
- **Depends on:** 1.7, 2.1
|
||||||
|
|
||||||
|
- [x] 2.3 [frontend] Implementation: `pages/account/model/concentration.ts` — состав разрезов, учитываемые позиции, валюты, точные суммы и доли, топ-5, «Прочее», порядок равных, имя эмитента, группы «не указан»
|
||||||
|
- **Covers:** `portfolio-concentration` — `Состав разрезов`; `portfolio-concentration` — `Названия элементов`; `portfolio-concentration` — `Название эмитента при разных названиях бренда`; `portfolio-concentration` — `Позиции, учитываемые в разрезе`; `portfolio-concentration` — `Сноска о не учтённых позициях`; `portfolio-concentration` — `Одна валюта за раз`; `portfolio-concentration` — `Доступные валюты вкладки`; `portfolio-concentration` — `Топ-5 и строка «Прочее»`; `portfolio-concentration` — `Порядок элементов с равной суммой`; `portfolio-concentration` — `Расчёт долей`; `portfolio-concentration` — `Показ долей`; `portfolio-concentration` — `Суммарная доля топ-5`; `portfolio-concentration` — `Группы «Сектор не указан» и «Эмитент не указан»`
|
||||||
|
- **Source:** design.md F1; analysis/data-mapping.md M3, I1–I8; analysis/test-cases/conc.md
|
||||||
|
- **Verification:** `npm run test:run -- src/pages/account/model/concentration.test.ts` по oracle TC-CONC-007, TC-CONC-008, TC-CONC-016, TC-CONC-020; сумма точных долей = 100 %; 6,25 → «6,3 %»; «< 0,1 %»; FUND исключён из «Эмитентов»
|
||||||
|
- **Depends on:** 2.2
|
||||||
|
|
||||||
|
- [x] 2.4 [frontend] Implementation: state `{accountId, tab, slice, currency}` в `AccountPage.vm.ts`, управляемые `Tabs`, вкладка только для одного счёта
|
||||||
|
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`; `portfolio-concentration` — `Вкладка в состояниях страницы`; `portfolio-concentration` — `Переключение разрезов`; `portfolio-concentration` — `Начальное состояние вкладки`; `portfolio-concentration` — `Независимый выбор валюты вкладки`; `portfolio-concentration` — `Состояния Portfolio на вкладке «Концентрация»`
|
||||||
|
- **Source:** design.md F2, F3; analysis/test-cases/conc.md TC-CONC-002, TC-CONC-005, TC-CONC-006, TC-CONC-015
|
||||||
|
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: вкладки нет для `all`, при загрузке, ошибке и пустом Portfolio; по умолчанию открыта «Позиции»; разрез и валюта сохраняются при переключении вкладок и сбрасываются при смене счёта; при исчезновении валюты выбирается первая
|
||||||
|
- **Depends on:** 2.3
|
||||||
|
|
||||||
|
- [x] 2.5 [frontend] Implementation: `Concentration.view` — список, раскрытие «Прочее», пустое состояние, сноска, выбор валюты, Alert «Данные неполные», подавление страничного Alert на вкладке
|
||||||
|
- **Covers:** `portfolio-concentration` — `Раскрытие строки «Прочее»`; `portfolio-concentration` — `Строки без переходов`; `portfolio-concentration` — `Пустое состояние разреза`; `portfolio-concentration` — `Предупреждение о неполных данных`
|
||||||
|
- **Source:** design.md F2, F4, «UI Implementation»; analysis/ux-design.md «UI States», «Responsive Behavior»; visuals v2 (SHA-256 по ux-design «Materials» сверены до начала)
|
||||||
|
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: при `partial` на «Концентрации» одно сообщение «Данные неполные», на других вкладках — прежний Alert; строки не являются ссылками; browser-тест 320 px по образцу `position-layout.browser.test.tsx` без горизонтального скролла страницы
|
||||||
|
- **Depends on:** 2.4
|
||||||
|
|
||||||
|
- [x] 2.6 [frontend] Implementation: DonutChart — центр-оверлей, обводка и штриховка через `cellProps`, цифры ранга, текстовый эквивалент
|
||||||
|
- **Covers:** `portfolio-concentration` — `Диаграмма распределения разреза`; `portfolio-concentration` — `Центр диаграммы`; `portfolio-concentration` — `Доступность диаграммы без цвета`
|
||||||
|
- **Source:** design.md F5; analysis/ux-design.md «Accessibility», D7; analysis/test-cases/conc.md TC-CONC-027
|
||||||
|
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: 7 элементов → 6 сегментов в порядке списка; центр «Топ-5 · 95 %» и «1 000 ₽»; при ≤ 5 элементах только сумма; `role="img"` и `aria-describedby` с названиями, суммами и долями
|
||||||
|
- **Depends on:** 2.5
|
||||||
|
|
||||||
|
- [x] 2.7 [frontend] Implementation: подписи секторов через общий словарь `stock.sectors` + ключ `government` («Государственный сектор»), видимый и в каталогах
|
||||||
|
- **Covers:** `portfolio-concentration` — `Подписи секторов`; `portfolio-concentration` — `Русские подписи известных секторов`
|
||||||
|
- **Source:** design.md F6; решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); analysis/test-cases/conc.md TC-CONC-024
|
||||||
|
- **Verification:** `npm run test:run`: 11 кодов из таблицы PC дают русские подписи; `other` выводится как есть; тест `StockCompany` (страница акции): `sector: "government"` отображается как «Сектор: Государственный сектор» — owner-approved изменение вне Specs (design.md F6, [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084))
|
||||||
|
- **Depends on:** 2.3
|
||||||
|
|
||||||
|
- [x] 2.8 [frontend] Automated Testing: регрессия вкладки «Позиции» и «Структуры портфеля»
|
||||||
|
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`
|
||||||
|
- **Source:** analysis/qa-analysis.md «Regression Impact»; analysis/test-cases/reg.md TC-REG-001
|
||||||
|
- **Verification:** существующие `AccountPage.test.tsx`, `position-groups.test.tsx`, тесты `widgets/portfolio-structure` проходят без изменения ожиданий
|
||||||
|
- **Depends on:** 2.6, 2.7
|
||||||
|
|
||||||
|
- [x] 2.9 [frontend] Verification: полный gate и PR в `develop` со ссылкой на WealthLedger/main#48 и backend PR
|
||||||
|
- **Covers:** N/A — финальный gate repo
|
||||||
|
- **Source:** frontend `.ai/conventions/engineering.md`; `.ai/conventions/engineering-workflow.md` «Validation → PR»
|
||||||
|
- **Verification:** `npm run verify` зелёный на финальной ревизии; PR открыт со ссылками на `WealthLedger/main#48` и backend PR
|
||||||
|
- **Depends on:** 2.8
|
||||||
|
|
||||||
|
## 3. Review и QA до G4
|
||||||
|
|
||||||
|
- [ ] 3.1 [specs] Independent Review: read-only review backend и frontend PR против Specs, design и analysis
|
||||||
|
- **Covers:** `portfolio-view` — `Результат получения сектора и эмитента`; `portfolio-concentration` — `Расчёт долей`
|
||||||
|
- **Source:** design.md целиком; analysis/system-analysis.md; analysis/failure-model.md
|
||||||
|
- **Verification:** отчёт review приложен к Gate Log G4 в process-record; blocker/major закрыты или возвращены в upstream-артефакт
|
||||||
|
- **Depends on:** 1.8, 2.9
|
||||||
|
|
||||||
|
- [ ] 3.2 [specs] QA Verification: прогон test-cases на локальном backend + frontend
|
||||||
|
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-concentration` — `Состояния Portfolio на вкладке «Концентрация»`
|
||||||
|
- **Source:** analysis/qa-analysis.md «Coverage Matrix», «Entry / Exit Criteria»; analysis/test-cases/conc.md, meta.md, reg.md
|
||||||
|
- **Verification:** результаты TC-CONC-002/005/006/007/008/015/016/020/024/027, TC-META-002/003/004/005, TC-REG-001/004 записаны pass/fail; F01–F16 покрыты; ручной блок (макеты v2, контраст, клавиатура, скринридер) зафиксирован
|
||||||
|
- **Depends on:** 1.8, 2.9
|
||||||
|
|
||||||
|
- [ ] 3.3 [specs] QA Verification: exploratory Q-D1/Q-D2/Q-F3 на реальном токене владельца
|
||||||
|
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-concentration` — `Группы «Сектор не указан» и «Эмитент не указан»`
|
||||||
|
- **Source:** analysis/qa-analysis.md «Gaps / Blockers», «Test Data and Environment Needs»; решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)
|
||||||
|
- **Verification:** записаны полнота сектора и бренда у позиций владельца (доля стоимости без значения, наличие `government` и фондов без сектора), число RPC и длительность первого обхода, отсутствие RESOURCE_EXHAUSTED / queue full; шаг Portfolio укладывается в `WORK_WAIT_BOUND_MS`
|
||||||
|
- **Depends on:** 3.2
|
||||||
|
|
||||||
|
- [ ] 3.4 [backend] Verification: откат backend-bundle на копии данных до G4
|
||||||
|
- **Covers:** N/A — проверка отката (design.md «Migration / Rollout / Rollback»)
|
||||||
|
- **Source:** design.md «Migration / Rollout / Rollback» (Backend); `deploy/README.md` «Ручной откат и восстановление»; `docs/architecture/deployment.md` «Откат image»
|
||||||
|
- **Verification:** на disposable копии БД с применённой миграцией `TbankInstrumentClassification`: (1) предыдущий backend-image, запущенный как при ручном откате (`up --no-deps backend`, без сервиса `migration`), проходит healthcheck, GET Portfolio отвечает `200` на Snapshot с полями `sector`/`issuer`; (2) из предыдущего image `node node_modules/prisma/build/index.js migrate status` и `migrate deploy` — записаны exit code и вывод (ожидание: status сообщает о миграции, отсутствующей локально; deploy не падает). Результат записан в Gate Log G4; при падении deploy — finding в design
|
||||||
|
- **Depends on:** 1.8
|
||||||
|
|
||||||
|
## 4. Docs (по analysis/docs-impact.md, после merge кода в `develop`)
|
||||||
|
|
||||||
|
- [x] 4.1 [docs] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
|
||||||
|
- **Covers:** N/A — организационный шаг cross-repo delivery
|
||||||
|
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch»; analysis/docs-impact.md «Summary» (правки AS-IS только после merge кода)
|
||||||
|
- **Verification:** backend и frontend PR слиты в `develop`; `git -C docs branch --show-current` выводит `feature/48-account-top-positions`
|
||||||
|
- **Depends on:** 1.8, 2.9, 3.3
|
||||||
|
|
||||||
|
- [x] 4.2 [docs] Docs: домен Portfolio — `overview.md`, при необходимости `concentration.md`, `data-reference.md`, `flows/portfolio-loading.md`, условная строка в `all-accounts.md`
|
||||||
|
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`; `portfolio-view` — `Нормализованный состав Portfolio`
|
||||||
|
- **Source:** analysis/docs-impact.md «Documentation Changes» (строки домена и flow)
|
||||||
|
- **Verification:** `npm run verify` в docs; текст соответствует реализованному AS-IS
|
||||||
|
- **Depends on:** 4.1
|
||||||
|
|
||||||
|
- [x] 4.3 [docs] Docs: интеграция и архитектура — `integrations/tbank.md`, `architecture/cross-cutting/data-freshness.md`, `architecture/containers.md`
|
||||||
|
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-view` — `Обновление сектора и эмитента`
|
||||||
|
- **Source:** analysis/docs-impact.md «Documentation Changes» (Integration, Architecture); design.md B1, B4
|
||||||
|
- **Verification:** `npm run verify` в docs
|
||||||
|
- **Depends on:** 4.1
|
||||||
|
|
||||||
|
- [x] 4.4 [docs] Docs: ADR 022 `stored-instrument-classification` (Accepted, AS-IS), строка в `adr/README.md`, ссылка в Status ADR 019
|
||||||
|
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
|
||||||
|
- **Source:** analysis/docs-impact.md «ADR Decision»; design.md «ADR Candidates», «Solution Assessment»; решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)
|
||||||
|
- **Verification:** `npm run verify` в docs; текст ADR 019 кроме строки Status не изменён
|
||||||
|
- **Depends on:** 4.1
|
||||||
|
|
||||||
|
- [x] 4.5 [docs] Docs: глоссарий и навигация — `glossary.md`, `overview.md`, `README.md`
|
||||||
|
- **Covers:** `portfolio-concentration` — `Переключение разрезов`
|
||||||
|
- **Source:** analysis/docs-impact.md «Glossary / Navigation Impact»
|
||||||
|
- **Verification:** `npm run verify` в docs; ссылки из навигации ведут на домен Portfolio
|
||||||
|
- **Depends on:** 4.2
|
||||||
|
|
||||||
|
- [x] 4.6 [docs] Verification: PR в `develop` со ссылкой на WealthLedger/main#48 и code PR
|
||||||
|
- **Covers:** N/A — финальный gate repo
|
||||||
|
- **Source:** `.ai/conventions/engineering-workflow.md` «Validation → PR»
|
||||||
|
- **Verification:** `npm run verify` зелёный; PR открыт со ссылками на `WealthLedger/main#48`, backend и frontend PR
|
||||||
|
- **Depends on:** 4.2, 4.3, 4.4, 4.5
|
||||||
|
|
||||||
|
## 5. Specs: G4 и завершение change
|
||||||
|
|
||||||
|
- [ ] 5.1 [specs] Verification: запись G4 в process-record с evidence задач 1–4
|
||||||
|
- **Covers:** N/A — gate протокол
|
||||||
|
- **Source:** instruction `process-record` (Readiness Checklist, Gate Log)
|
||||||
|
- **Verification:** `npx -y @fission-ai/openspec@1.13.1 validate account-top-positions --store wealth-ledger-specs --strict` valid; `git diff --check` чист; строка G4 содержит ссылки на PR, отчёты 3.1–3.3 и решение владельца
|
||||||
|
- **Depends on:** 3.1, 3.2, 3.3, 3.4, 4.6
|
||||||
|
|
||||||
|
- [ ] 5.2 [specs] Verification: native archive change после G4 и PR в `develop` со ссылкой на WealthLedger/main#48
|
||||||
|
- **Covers:** N/A — native sync/archive выполняет CLI, canonical specs вручную не правятся
|
||||||
|
- **Source:** schema `wealth-ledger-sdd-lab` apply/archive instruction; `.ai/conventions/engineering-workflow.md` «Merge → Issue Closed»
|
||||||
|
- **Verification:** `npx -y @fission-ai/openspec@1.13.1 archive account-top-positions --store wealth-ledger-specs` завершён; `openspec validate --all --strict` valid; PR specs открыт; Issue main#48 закрывается после merge всех PR
|
||||||
|
- **Depends on:** 5.1
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" role="img">
|
||||||
|
<title>Концентрация — desktop — разрез Эмитенты, одна валюта, предупреждение</title>
|
||||||
|
<desc>Макет вкладки Концентрация, разрез Эмитенты, валюта RUB. Центр: Топ-5 · 94,2 %, 3 527 000 ₽. Сбер 31,8 %; Газпром 22,1 %; ЛУКОЙЛ 18,1 %; Эмитент не указан 11,6 %; Яндекс 10,5 %; РЖД 5,8 %; предупреждение «Данные неполные»</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||||
|
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||||
|
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||||
|
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="1280" height="800" rx="0" fill="#e9ecef" />
|
||||||
|
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
|
||||||
|
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
|
||||||
|
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||||
|
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
|
||||||
|
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||||
|
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||||
|
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||||
|
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
|
||||||
|
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
|
||||||
|
<rect x="120" y="120" width="1040" height="48" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1" /><rect x="120" y="120" width="4" height="48" rx="2" fill="#2563eb" /><text x="140" y="149" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="start">Данные неполные</text>
|
||||||
|
<rect x="120" y="184" width="1040" height="568" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<rect x="144" y="208" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<text x="204.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||||
|
<text x="317.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||||
|
<text x="430.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||||
|
<rect x="487.0" y="212" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<text x="543.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Эмитенты</text>
|
||||||
|
<rect x="144" y="272" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||||
|
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 330.00 A110 110 0 0 1 392.24 485.30 L363.08 472.12 A78 78 0 0 0 292.00 362.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="396.2" y="376.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||||
|
<path d="M392.24 485.30 A110 110 0 0 1 265.51 546.76 L273.22 515.71 A78 78 0 0 0 363.08 472.12 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="346.1" y="555.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||||
|
<path d="M265.51 546.76 A110 110 0 0 1 183.93 460.50 L215.37 454.54 A78 78 0 0 0 273.22 515.71 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="201.9" y="529.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||||
|
<path d="M183.93 460.50 A110 110 0 0 1 197.82 383.17 L225.22 399.70 A78 78 0 0 0 215.37 454.54 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M183.93 460.50 A110 110 0 0 1 197.82 383.17 L225.22 399.70 A78 78 0 0 0 215.37 454.54 Z" fill="url(#hatch)"/>
|
||||||
|
<text x="170.0" y="422.1" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||||
|
<path d="M197.82 383.17 A110 110 0 0 1 252.72 337.25 L264.14 367.14 A78 78 0 0 0 225.22 399.70 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="212.4" y="348.9" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||||
|
<path d="M252.72 337.25 A110 110 0 0 1 292.00 330.00 L292.00 362.00 A78 78 0 0 0 264.14 367.14 Z" fill="#eff6ff" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="269.5" y="322.1" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">6</text>
|
||||||
|
<text x="292.0" y="434" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 94,2 %</text>
|
||||||
|
<text x="292.0" y="462" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">3 527 000 ₽</text></g>
|
||||||
|
<text x="292.0" y="602" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Эмитенты · RUB</text>
|
||||||
|
<text x="476" y="305" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
|
||||||
|
<circle cx="506" cy="300" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="296" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сбер</text>
|
||||||
|
<text x="1136" y="296" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">1 120 000 ₽</text>
|
||||||
|
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">31,8 %</text>
|
||||||
|
<rect x="528" y="326" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="326" width="193.07059824213212" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="346" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="381" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
|
||||||
|
<circle cx="506" cy="376" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="372" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Газпром</text>
|
||||||
|
<text x="1136" y="372" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">780 000 ₽</text>
|
||||||
|
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">22,1 %</text>
|
||||||
|
<rect x="528" y="402" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="402" width="134.45988091862773" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="422" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="457" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
|
||||||
|
<circle cx="506" cy="452" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="448" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">ЛУКОЙЛ</text>
|
||||||
|
<text x="1136" y="448" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">640 000 ₽</text>
|
||||||
|
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">18,1 %</text>
|
||||||
|
<rect x="528" y="478" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="478" width="110.32605613836121" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="498" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="533" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
|
||||||
|
<circle cx="506" cy="528" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="506" cy="528" r="5" fill="url(#hatch)"/>
|
||||||
|
<text x="528" y="524" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Эмитент не указан</text>
|
||||||
|
<text x="1136" y="524" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">410 000 ₽</text>
|
||||||
|
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">11,6 %</text>
|
||||||
|
<rect x="528" y="554" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="554" width="70.67762971363764" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="554" width="70.67762971363764" height="6" rx="3" fill="url(#hatch)" />
|
||||||
|
<rect x="528" y="574" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="609" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
|
||||||
|
<circle cx="506" cy="604" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="600" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Яндекс</text>
|
||||||
|
<text x="1136" y="600" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">372 000 ₽</text>
|
||||||
|
<text x="1136" y="620" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">10,5 %</text>
|
||||||
|
<rect x="528" y="630" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="630" width="64.12702013042247" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="650" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="685" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">6</text>
|
||||||
|
<circle cx="506" cy="680" r="5" fill="#eff6ff" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="676" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">РЖД</text>
|
||||||
|
<text x="1136" y="676" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">205 000 ₽</text>
|
||||||
|
<text x="1136" y="696" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">5,8 %</text>
|
||||||
|
<rect x="528" y="706" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="706" width="35.33881485681882" height="6" rx="3" fill="#2563eb" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="932" viewBox="0 0 1280 932" role="img">
|
||||||
|
<title>Концентрация — desktop — разрез Секторы, «Прочее» раскрыто, фокус на «Прочее»</title>
|
||||||
|
<desc>Макет вкладки Концентрация, разрез Секторы, валюта RUB. Центр: Топ-5 · 92,1 %, 4 079 330 ₽. Финансы 29,5 %; Государственный сектор 24,2 %; Энергетика 20,7 %; Информационные технологии 9,9 %; Сектор не указан 7,8 %; Прочее 7,9 %</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||||
|
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||||
|
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||||
|
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="1280" height="932" rx="0" fill="#e9ecef" />
|
||||||
|
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
|
||||||
|
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
|
||||||
|
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||||
|
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
|
||||||
|
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||||
|
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||||
|
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||||
|
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
|
||||||
|
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
|
||||||
|
<rect x="120" y="128" width="1040" height="756" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<rect x="144" y="152" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<text x="204.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||||
|
<text x="317.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||||
|
<rect x="374.0" y="156" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<text x="430.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Секторы</text>
|
||||||
|
<text x="543.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||||
|
<rect x="1024" y="152" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="1038" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M1112 169 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="1014" y="177" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">Валюта</text>
|
||||||
|
<rect x="144" y="216" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||||
|
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 274.00 A110 110 0 0 1 397.58 414.87 L366.87 405.89 A78 78 0 0 0 292.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="391.2" y="313.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||||
|
<path d="M397.58 414.87 A110 110 0 0 1 266.62 491.03 L274.01 459.90 A78 78 0 0 0 366.87 405.89 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="354.3" y="495.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||||
|
<path d="M266.62 491.03 A110 110 0 0 1 182.07 387.90 L214.05 386.76 A78 78 0 0 0 274.01 459.90 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="196.1" y="466.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||||
|
<path d="M182.07 387.90 A110 110 0 0 1 200.23 323.36 L226.92 341.00 A78 78 0 0 0 214.05 386.76 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="172.6" y="354.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||||
|
<path d="M200.23 323.36 A110 110 0 0 1 239.63 287.26 L254.87 315.41 A78 78 0 0 0 226.92 341.00 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M200.23 323.36 A110 110 0 0 1 239.63 287.26 L254.87 315.41 A78 78 0 0 0 226.92 341.00 Z" fill="url(#hatch)"/>
|
||||||
|
<text x="208.2" y="296.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||||
|
<path d="M239.63 287.26 A110 110 0 0 1 292.00 274.00 L292.00 306.00 A78 78 0 0 0 254.87 315.41 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<text x="292.0" y="378" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 92,1 %</text>
|
||||||
|
<text x="292.0" y="406" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">4 079 330 ₽</text></g>
|
||||||
|
<text x="292.0" y="546" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Секторы · RUB</text>
|
||||||
|
<text x="476" y="249" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
|
||||||
|
<circle cx="506" cy="244" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Финансы</text>
|
||||||
|
<text x="1136" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">1 204 500 ₽</text>
|
||||||
|
<text x="1136" y="260" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">29,5 %</text>
|
||||||
|
<rect x="528" y="270" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="270" width="179.52359823794592" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="290" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="325" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
|
||||||
|
<circle cx="506" cy="320" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Государственный сектор</text>
|
||||||
|
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">986 300 ₽</text>
|
||||||
|
<text x="1136" y="336" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">24,2 %</text>
|
||||||
|
<rect x="528" y="346" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="346" width="147.0021792794405" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="366" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="401" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
|
||||||
|
<circle cx="506" cy="396" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Энергетика</text>
|
||||||
|
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">845 700 ₽</text>
|
||||||
|
<text x="1136" y="412" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">20,7 %</text>
|
||||||
|
<rect x="528" y="422" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="422" width="126.046581178772" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="442" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="477" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
|
||||||
|
<circle cx="506" cy="472" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="528" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Информационные технологии</text>
|
||||||
|
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">402 100 ₽</text>
|
||||||
|
<text x="1136" y="488" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">9,9 %</text>
|
||||||
|
<rect x="528" y="498" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="498" width="59.93062586258038" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="518" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="553" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
|
||||||
|
<circle cx="506" cy="548" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="506" cy="548" r="5" fill="url(#hatch)"/>
|
||||||
|
<text x="528" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сектор не указан</text>
|
||||||
|
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">318 600 ₽</text>
|
||||||
|
<text x="1136" y="564" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">7,8 %</text>
|
||||||
|
<rect x="528" y="574" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="574" width="47.48544491374809" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="574" width="47.48544491374809" height="6" rx="3" fill="url(#hatch)" />
|
||||||
|
<rect x="528" y="594" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<rect x="464" y="596" width="680" height="72" rx="10" fill="#eff6ff" />
|
||||||
|
<rect x="462" y="594" width="684" height="76" rx="12" fill="none" stroke="#2563eb" stroke-width="2" />
|
||||||
|
<text x="476" y="629" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start"></text>
|
||||||
|
<circle cx="506" cy="624" r="5" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
|
||||||
|
<text x="528" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
|
||||||
|
<text x="528" y="640" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
|
||||||
|
<text x="1102" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">322 130 ₽</text>
|
||||||
|
<text x="1102" y="640" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">7,9 %</text>
|
||||||
|
<path d="M1116 626 l6 -6 l6 6" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="528" y="650" width="574" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="650" width="45.32671296511927" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||||
|
<rect x="506" y="666" width="2" height="176" rx="1" fill="#dee2e6" />
|
||||||
|
<text x="528" y="694" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">6</text>
|
||||||
|
<text x="552" y="694" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Потребительский сектор</text>
|
||||||
|
<text x="1102" y="694" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">164 200 ₽</text>
|
||||||
|
<text x="1102" y="712" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">4 %</text>
|
||||||
|
<rect x="552" y="720" width="550" height="4" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="552" y="720" width="22.138439400587842" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||||
|
<text x="528" y="754" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">7</text>
|
||||||
|
<text x="552" y="754" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Телекоммуникации</text>
|
||||||
|
<text x="1102" y="754" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">96 410 ₽</text>
|
||||||
|
<text x="1102" y="772" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">2,4 %</text>
|
||||||
|
<rect x="552" y="780" width="550" height="4" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="552" y="780" width="12.998580649273288" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||||
|
<text x="528" y="814" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">8</text>
|
||||||
|
<text x="552" y="814" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Материалы</text>
|
||||||
|
<text x="1102" y="814" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">61 520 ₽</text>
|
||||||
|
<text x="1102" y="832" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">1,5 %</text>
|
||||||
|
<rect x="552" y="840" width="550" height="4" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="552" y="840" width="8.294499341803679" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,104 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="780" viewBox="0 0 1280 780" role="img">
|
||||||
|
<title>Концентрация — desktop — разрез Акции</title>
|
||||||
|
<desc>Макет вкладки Концентрация, разрез Акции, валюта RUB. Центр: Топ-5 · 86,5 %, 2 235 310 ₽. Сбербанк 27,4 %; ЛУКОЙЛ 22,3 %; Яндекс 16,6 %; Газпром 11,8 %; Норникель 8,4 %; Прочее 13,5 %</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||||
|
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||||
|
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||||
|
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="1280" height="780" rx="0" fill="#e9ecef" />
|
||||||
|
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
|
||||||
|
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
|
||||||
|
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||||
|
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
|
||||||
|
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||||
|
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||||
|
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||||
|
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
|
||||||
|
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
|
||||||
|
<rect x="120" y="128" width="1040" height="604" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<rect x="144" y="152" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<rect x="148.0" y="156" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<text x="204.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Акции</text>
|
||||||
|
<text x="317.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||||
|
<text x="430.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||||
|
<text x="543.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||||
|
<rect x="1024" y="152" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="1038" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M1112 169 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="1014" y="177" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">Валюта</text>
|
||||||
|
<rect x="144" y="216" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||||
|
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 274.00 A110 110 0 0 1 400.75 400.53 L369.11 395.72 A78 78 0 0 0 292.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="386.0" y="307.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||||
|
<path d="M400.75 400.53 A110 110 0 0 1 294.14 493.98 L293.52 461.99 A78 78 0 0 0 369.11 395.72 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="373.7" y="481.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||||
|
<path d="M294.14 493.98 A110 110 0 0 1 197.93 441.02 L225.30 424.43 A78 78 0 0 0 293.52 461.99 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="232.2" y="496.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||||
|
<path d="M197.93 441.02 A110 110 0 0 1 184.15 362.36 L215.52 368.66 A78 78 0 0 0 225.30 424.43 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="169.9" y="409.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||||
|
<path d="M184.15 362.36 A110 110 0 0 1 209.70 311.02 L233.64 332.25 A78 78 0 0 0 215.52 368.66 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="181.0" y="332.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||||
|
<path d="M209.70 311.02 A110 110 0 0 1 292.00 274.00 L292.00 306.00 A78 78 0 0 0 233.64 332.25 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<text x="292.0" y="378" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 86,5 %</text>
|
||||||
|
<text x="292.0" y="406" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">2 235 310 ₽</text></g>
|
||||||
|
<text x="292.0" y="546" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Акции · RUB</text>
|
||||||
|
<text x="476" y="249" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
|
||||||
|
<circle cx="506" cy="244" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<rect x="528" y="224" width="40" height="40" rx="8" fill="#e7f5ff" /><text x="548.0" y="249.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">С</text>
|
||||||
|
<text x="580" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сбербанк</text>
|
||||||
|
<text x="580" y="260" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">SBER</text>
|
||||||
|
<text x="1136" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">612 480 ₽</text>
|
||||||
|
<text x="1136" y="260" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">27,4 %</text>
|
||||||
|
<rect x="580" y="270" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="580" y="270" width="152.34525859947837" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="290" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="325" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
|
||||||
|
<circle cx="506" cy="320" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<rect x="528" y="300" width="40" height="40" rx="8" fill="#fff4e6" /><text x="548.0" y="325.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Л</text>
|
||||||
|
<text x="580" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">ЛУКОЙЛ</text>
|
||||||
|
<text x="580" y="336" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">LKOH</text>
|
||||||
|
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">498 250 ₽</text>
|
||||||
|
<text x="1136" y="336" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">22,3 %</text>
|
||||||
|
<rect x="580" y="346" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="580" y="346" width="123.93225100769021" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="366" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="401" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
|
||||||
|
<circle cx="506" cy="396" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<rect x="528" y="376" width="40" height="40" rx="8" fill="#ebfbee" /><text x="548.0" y="401.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Я</text>
|
||||||
|
<text x="580" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Яндекс</text>
|
||||||
|
<text x="580" y="412" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">YDEX</text>
|
||||||
|
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">371 900 ₽</text>
|
||||||
|
<text x="1136" y="412" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">16,6 %</text>
|
||||||
|
<rect x="580" y="422" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="580" y="422" width="92.50457430960358" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="442" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="477" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
|
||||||
|
<circle cx="506" cy="472" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<rect x="528" y="452" width="40" height="40" rx="8" fill="#f3d9fa" /><text x="548.0" y="477.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Г</text>
|
||||||
|
<text x="580" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Газпром</text>
|
||||||
|
<text x="580" y="488" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">GAZP</text>
|
||||||
|
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">264 300 ₽</text>
|
||||||
|
<text x="1136" y="488" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">11,8 %</text>
|
||||||
|
<rect x="580" y="498" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="580" y="498" width="65.7406802635876" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="518" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="553" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
|
||||||
|
<circle cx="506" cy="548" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<rect x="528" y="528" width="40" height="40" rx="8" fill="#fff3bf" /><text x="548.0" y="553.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Н</text>
|
||||||
|
<text x="580" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Норникель</text>
|
||||||
|
<text x="580" y="564" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">GMKN</text>
|
||||||
|
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">187 650 ₽</text>
|
||||||
|
<text x="1136" y="564" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">8,4 %</text>
|
||||||
|
<rect x="580" y="574" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="580" y="574" width="46.675136781922866" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="528" y="594" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<text x="476" y="629" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start"></text>
|
||||||
|
<circle cx="506" cy="624" r="5" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
|
||||||
|
<text x="528" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
|
||||||
|
<text x="528" y="640" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
|
||||||
|
<text x="1102" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">300 730 ₽</text>
|
||||||
|
<text x="1102" y="640" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">13,5 %</text>
|
||||||
|
<path d="M1116 620 l6 6 l6 -6" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="528" y="650" width="574" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="528" y="650" width="77.22374972598878" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||||
|
<text x="476" y="700" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 2</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="474" viewBox="0 0 390 474" role="img">
|
||||||
|
<title>Концентрация — mobile — пустой разрез Облигации</title>
|
||||||
|
<desc>Макет вкладки Концентрация (mobile 390), разрез Облигации, валюта RUB. пустое состояние</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||||
|
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||||
|
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||||
|
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="390" height="474" rx="0" fill="#e9ecef" />
|
||||||
|
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
|
||||||
|
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
|
||||||
|
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
|
||||||
|
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
|
||||||
|
<g clip-path="url(#tabclip)">
|
||||||
|
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||||
|
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
|
||||||
|
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||||
|
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||||
|
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||||
|
</g>
|
||||||
|
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
|
||||||
|
<rect x="16" y="116" width="358" height="326" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<rect x="28" y="132" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<text x="72.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||||
|
<rect x="113.5" y="136" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<text x="154.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Облигации</text>
|
||||||
|
<text x="235.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||||
|
<text x="317.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||||
|
<text x="32" y="209" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Валюта</text>
|
||||||
|
<rect x="92" y="184" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="106" y="209" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M180 201 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="28" y="240" width="334" height="130" rx="12" fill="#f8f9fa" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<text x="44" y="276" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="start">Облигации в RUB: нет позиций</text>
|
||||||
|
<text x="44" y="304" font-family="system-ui, sans-serif" font-size="14" font-weight="400" fill="#495057" text-anchor="start">Нет позиций с положительной оценкой</text>
|
||||||
|
<text x="44" y="324" font-family="system-ui, sans-serif" font-size="14" font-weight="400" fill="#495057" text-anchor="start">в этом разрезе и выбранной валюте.</text>
|
||||||
|
<text x="32" y="404" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,80 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="1020" viewBox="0 0 390 1020" role="img">
|
||||||
|
<title>Концентрация — mobile — разрез Эмитенты, пять элементов, предупреждение</title>
|
||||||
|
<desc>Макет вкладки Концентрация (mobile 390), разрез Эмитенты, валюта RUB. Центр: 3 322 000 ₽. Сбер 33,7 %; Газпром 23,5 %; ЛУКОЙЛ 19,3 %; Эмитент не указан 12,3 %; Яндекс 11,2 %; предупреждение «Данные неполные»</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||||
|
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||||
|
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||||
|
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="390" height="1020" rx="0" fill="#e9ecef" />
|
||||||
|
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
|
||||||
|
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
|
||||||
|
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
|
||||||
|
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
|
||||||
|
<g clip-path="url(#tabclip)">
|
||||||
|
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||||
|
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
|
||||||
|
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||||
|
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||||
|
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||||
|
</g>
|
||||||
|
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
|
||||||
|
<rect x="16" y="112" width="358" height="48" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1" /><rect x="16" y="112" width="4" height="48" rx="2" fill="#2563eb" /><text x="36" y="141" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="start">Данные неполные</text>
|
||||||
|
<rect x="16" y="172" width="358" height="816" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<rect x="28" y="188" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<text x="72.75" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||||
|
<text x="154.25" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||||
|
<text x="235.75" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||||
|
<rect x="276.5" y="192" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<text x="317.25" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Эмитенты</text>
|
||||||
|
<rect x="28" y="244" width="334" height="304" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||||
|
<g role="img" aria-label="Диаграмма разреза"><path d="M195.00 274.00 A110 110 0 0 1 288.92 441.27 L261.60 424.61 A78 78 0 0 0 195.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="303.1" y="327.3" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||||
|
<path d="M288.92 441.27 A110 110 0 0 1 146.95 482.95 L160.93 454.17 A78 78 0 0 0 261.60 424.61 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="229.9" y="507.0" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||||
|
<path d="M146.95 482.95 A110 110 0 0 1 85.46 373.92 L117.33 376.85 A78 78 0 0 0 160.93 454.17 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="87.0" y="448.9" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||||
|
<path d="M85.46 373.92 A110 110 0 0 1 123.83 300.12 L144.54 324.52 A78 78 0 0 0 117.33 376.85 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M85.46 373.92 A110 110 0 0 1 123.83 300.12 L144.54 324.52 A78 78 0 0 0 117.33 376.85 Z" fill="url(#hatch)"/>
|
||||||
|
<text x="85.0" y="330.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||||
|
<path d="M123.83 300.12 A110 110 0 0 1 195.00 274.00 L195.00 306.00 A78 78 0 0 0 144.54 324.52 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="152.3" y="271.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||||
|
<text x="195.0" y="392" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">3 322 000 ₽</text></g>
|
||||||
|
<text x="195.0" y="532" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#495057" text-anchor="middle">Эмитенты · RUB</text>
|
||||||
|
<circle cx="38" cy="578" r="6" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="54" y="580" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">1. Сбер</text>
|
||||||
|
<text x="358" y="580" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">1 120 000 ₽</text>
|
||||||
|
<text x="358" y="598" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">33,7 %</text>
|
||||||
|
<rect x="54" y="610" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="54" y="610" width="102.4924744130042" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="54" y="632" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<circle cx="38" cy="662" r="6" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="54" y="664" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">2. Газпром</text>
|
||||||
|
<text x="358" y="664" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">780 000 ₽</text>
|
||||||
|
<text x="358" y="682" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">23,5 %</text>
|
||||||
|
<rect x="54" y="694" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="54" y="694" width="71.37868753762794" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="54" y="716" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<circle cx="38" cy="746" r="6" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="54" y="748" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">3. ЛУКОЙЛ</text>
|
||||||
|
<text x="358" y="748" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">640 000 ₽</text>
|
||||||
|
<text x="358" y="766" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">19,3 %</text>
|
||||||
|
<rect x="54" y="778" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="54" y="778" width="58.56712823600241" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="54" y="800" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<circle cx="38" cy="830" r="6" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="38" cy="830" r="6" fill="url(#hatch)"/>
|
||||||
|
<text x="54" y="832" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">4. Эмитент не указан</text>
|
||||||
|
<text x="358" y="832" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">410 000 ₽</text>
|
||||||
|
<text x="358" y="850" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">12,3 %</text>
|
||||||
|
<rect x="54" y="862" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="54" y="862" width="37.519566526189045" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="54" y="862" width="37.519566526189045" height="6" rx="3" fill="url(#hatch)" />
|
||||||
|
<rect x="54" y="884" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<circle cx="38" cy="914" r="6" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="54" y="916" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">5. Яндекс</text>
|
||||||
|
<text x="358" y="916" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">372 000 ₽</text>
|
||||||
|
<text x="358" y="934" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">11,2 %</text>
|
||||||
|
<rect x="54" y="946" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="54" y="946" width="34.0421432871764" height="6" rx="3" fill="#2563eb" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,101 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="1144" viewBox="0 0 390 1144" role="img">
|
||||||
|
<title>Концентрация — mobile — разрез Акции</title>
|
||||||
|
<desc>Макет вкладки Концентрация (mobile 390), разрез Акции, валюта RUB. Центр: Топ-5 · 86,5 %, 2 235 310 ₽. Сбербанк 27,4 %; ЛУКОЙЛ 22,3 %; Яндекс 16,6 %; Газпром 11,8 %; Норникель 8,4 %; Прочее 13,5 %</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||||
|
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||||
|
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||||
|
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="390" height="1144" rx="0" fill="#e9ecef" />
|
||||||
|
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
|
||||||
|
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
|
||||||
|
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
|
||||||
|
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
|
||||||
|
<g clip-path="url(#tabclip)">
|
||||||
|
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||||
|
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
|
||||||
|
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||||
|
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||||
|
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||||
|
</g>
|
||||||
|
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
|
||||||
|
<rect x="16" y="116" width="358" height="996" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<rect x="28" y="132" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||||
|
<rect x="32.0" y="136" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||||
|
<text x="72.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Акции</text>
|
||||||
|
<text x="154.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||||
|
<text x="235.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||||
|
<text x="317.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||||
|
<text x="32" y="209" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Валюта</text>
|
||||||
|
<rect x="92" y="184" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="106" y="209" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M180 201 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="28" y="240" width="334" height="304" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||||
|
<g role="img" aria-label="Диаграмма разреза"><path d="M195.00 270.00 A110 110 0 0 1 303.75 396.53 L272.11 391.72 A78 78 0 0 0 195.00 302.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="289.0" y="303.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||||
|
<path d="M303.75 396.53 A110 110 0 0 1 197.14 489.98 L196.52 457.99 A78 78 0 0 0 272.11 391.72 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="276.7" y="477.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||||
|
<path d="M197.14 489.98 A110 110 0 0 1 100.93 437.02 L128.30 420.43 A78 78 0 0 0 196.52 457.99 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="135.2" y="492.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||||
|
<path d="M100.93 437.02 A110 110 0 0 1 87.15 358.36 L118.52 364.66 A78 78 0 0 0 128.30 420.43 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="72.9" y="405.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||||
|
<path d="M87.15 358.36 A110 110 0 0 1 112.70 307.02 L136.64 328.25 A78 78 0 0 0 118.52 364.66 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<text x="84.0" y="328.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||||
|
<path d="M112.70 307.02 A110 110 0 0 1 195.00 270.00 L195.00 302.00 A78 78 0 0 0 136.64 328.25 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<text x="195.0" y="374" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 86,5 %</text>
|
||||||
|
<text x="195.0" y="402" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">2 235 310 ₽</text></g>
|
||||||
|
<text x="195.0" y="528" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#495057" text-anchor="middle">Акции · RUB</text>
|
||||||
|
<rect x="32" y="560" width="40" height="40" rx="8" fill="#e7f5ff" /><text x="52.0" y="585.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">С</text>
|
||||||
|
<circle cx="70" cy="598" r="6" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="84" y="576" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">1. Сбербанк</text>
|
||||||
|
<text x="84" y="594" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">SBER</text>
|
||||||
|
<text x="358" y="576" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">612 480 ₽</text>
|
||||||
|
<text x="358" y="594" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">27,4 %</text>
|
||||||
|
<rect x="84" y="606" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="84" y="606" width="75.07662024506668" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="84" y="628" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<rect x="32" y="644" width="40" height="40" rx="8" fill="#fff4e6" /><text x="52.0" y="669.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Л</text>
|
||||||
|
<circle cx="70" cy="682" r="6" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="84" y="660" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">2. ЛУКОЙЛ</text>
|
||||||
|
<text x="84" y="678" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">LKOH</text>
|
||||||
|
<text x="358" y="660" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">498 250 ₽</text>
|
||||||
|
<text x="358" y="678" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">22,3 %</text>
|
||||||
|
<rect x="84" y="690" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="84" y="690" width="61.07452657573223" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="84" y="712" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<rect x="32" y="728" width="40" height="40" rx="8" fill="#ebfbee" /><text x="52.0" y="753.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Я</text>
|
||||||
|
<circle cx="70" cy="766" r="6" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="84" y="744" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">3. Яндекс</text>
|
||||||
|
<text x="84" y="762" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">YDEX</text>
|
||||||
|
<text x="358" y="744" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">371 900 ₽</text>
|
||||||
|
<text x="358" y="762" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">16,6 %</text>
|
||||||
|
<rect x="84" y="774" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="84" y="774" width="45.58678662020033" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="84" y="796" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<rect x="32" y="812" width="40" height="40" rx="8" fill="#f3d9fa" /><text x="52.0" y="837.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Г</text>
|
||||||
|
<circle cx="70" cy="850" r="6" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="84" y="828" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">4. Газпром</text>
|
||||||
|
<text x="84" y="846" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">GAZP</text>
|
||||||
|
<text x="358" y="828" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">264 300 ₽</text>
|
||||||
|
<text x="358" y="846" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">11,8 %</text>
|
||||||
|
<rect x="84" y="858" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="84" y="858" width="32.39738559752339" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="84" y="880" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<rect x="32" y="896" width="40" height="40" rx="8" fill="#fff3bf" /><text x="52.0" y="921.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Н</text>
|
||||||
|
<circle cx="70" cy="934" r="6" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||||
|
<text x="84" y="912" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">5. Норникель</text>
|
||||||
|
<text x="84" y="930" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">GMKN</text>
|
||||||
|
<text x="358" y="912" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">187 650 ₽</text>
|
||||||
|
<text x="358" y="930" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">8,4 %</text>
|
||||||
|
<rect x="84" y="942" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="84" y="942" width="23.00177604001235" height="6" rx="3" fill="#2563eb" />
|
||||||
|
<rect x="84" y="964" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||||
|
<circle cx="38" cy="994" r="6" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
|
||||||
|
<text x="54" y="996" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
|
||||||
|
<text x="54" y="1014" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
|
||||||
|
<text x="336" y="996" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">300 730 ₽</text>
|
||||||
|
<text x="336" y="1014" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">13,5 %</text>
|
||||||
|
<path d="M345 988 l5 5 l5 -5" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="54" y="1026" width="282" height="6" rx="3" fill="#f1f3f5" />
|
||||||
|
<rect x="54" y="1026" width="37.93919411625233" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||||
|
<text x="32" y="1082" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 2</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -1,294 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Deterministic checks for wealth-ledger-sdd-lab changes. OpenSpec CLI 1.13.1 has no conditional
|
|
||||||
// artifacts, gate records or archive policy, so this file covers only what the CLI cannot.
|
|
||||||
// It is the single source for profile, ordering, gate and readiness rules; schema.yaml points here.
|
|
||||||
// Semantic quality (contradictions, invented facts, design fit) stays with reviewers.
|
|
||||||
//
|
|
||||||
// Usage: node check.cjs <plan|apply|archive> <change-name> [--root <openspec-root>]
|
|
||||||
// plan — artifact consistency while planning; missing gates are notes, not errors.
|
|
||||||
// apply — plan + every apply blocker is an error.
|
|
||||||
// archive — apply + all tasks done, G4 approved, lab retrospective.
|
|
||||||
// Exit: 0 ok, 1 violations, 2 invocation/environment error.
|
|
||||||
"use strict";
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const SA_MODULES = ["data-mapping", "failure-model", "nfr", "observability"];
|
|
||||||
// Primary file of each conditional artifact, relative to the change directory.
|
|
||||||
const PRIMARY = {
|
|
||||||
research: "research.md",
|
|
||||||
"ux-design": "ux-design.md",
|
|
||||||
"system-analysis": "system-analysis/system-analysis.md",
|
|
||||||
"data-mapping": "system-analysis/data-mapping.md",
|
|
||||||
"failure-model": "system-analysis/failure-model.md",
|
|
||||||
nfr: "system-analysis/nfr.md",
|
|
||||||
observability: "system-analysis/observability.md",
|
|
||||||
"security-analysis": "security-analysis.md",
|
|
||||||
"test-cases": "test-cases",
|
|
||||||
"docs-impact": "docs-impact.md",
|
|
||||||
};
|
|
||||||
const CONDITIONAL = Object.keys(PRIMARY);
|
|
||||||
// A selected conditional input must exist before any of these files is written.
|
|
||||||
const BEFORE_SOLUTION = ["design.md", "qa-analysis.md", "tasks.md"];
|
|
||||||
const DOWNSTREAM = {
|
|
||||||
research: ["specs", ...BEFORE_SOLUTION],
|
|
||||||
"ux-design": BEFORE_SOLUTION,
|
|
||||||
"system-analysis": BEFORE_SOLUTION,
|
|
||||||
...Object.fromEntries(SA_MODULES.map((id) => [id, BEFORE_SOLUTION])),
|
|
||||||
"security-analysis": BEFORE_SOLUTION,
|
|
||||||
"test-cases": ["tasks.md"],
|
|
||||||
"docs-impact": ["tasks.md"],
|
|
||||||
};
|
|
||||||
// Gate that may approve each artifact. Complex changes approve system analysis at G2 (contract-first).
|
|
||||||
const SA_IDS = ["system-analysis", ...SA_MODULES];
|
|
||||||
const allowedGates = (id, complex, hasG2) => {
|
|
||||||
if (id === "proposal") return ["G1"];
|
|
||||||
if (id === "change-profile") return ["G1", "G2", "G3"]; // later profile changes need a recorded decision
|
|
||||||
if (id === "specs") return hasG2 ? ["G2"] : ["G3"];
|
|
||||||
if (SA_IDS.includes(id)) return complex ? ["G2"] : hasG2 ? ["G2", "G3"] : ["G3"];
|
|
||||||
return ["G3"];
|
|
||||||
};
|
|
||||||
const NOT_APPROVED = new Set(["research", "process-record"]); // input context / the log itself
|
|
||||||
const REF = /`([a-z0-9][a-z0-9/-]*)`\s*[—–-]\s*`([^`]+)`/g;
|
|
||||||
|
|
||||||
const fail = (msg) => { console.error(msg); process.exit(2); };
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const [mode, changeName] = args;
|
|
||||||
if (!["plan", "apply", "archive"].includes(mode) || !changeName) {
|
|
||||||
fail("Usage: node check.cjs <plan|apply|archive> <change-name> [--root <openspec-root>]");
|
|
||||||
}
|
|
||||||
const rootIdx = args.indexOf("--root");
|
|
||||||
if (rootIdx > 0 && !args[rootIdx + 1]) fail("--root needs a path");
|
|
||||||
const root = path.resolve(rootIdx > 0 ? args[rootIdx + 1] : path.join(__dirname, "..", ".."));
|
|
||||||
const changeDir = path.join(root, "changes", changeName);
|
|
||||||
if (!fs.existsSync(changeDir)) fail(`Change directory not found: ${changeDir}`);
|
|
||||||
|
|
||||||
const errors = [];
|
|
||||||
const warnings = [];
|
|
||||||
const notes = [];
|
|
||||||
const rel = (p) => path.relative(changeDir, p);
|
|
||||||
const read = (p) => fs.readFileSync(p, "utf8");
|
|
||||||
const at = (p) => path.join(changeDir, p);
|
|
||||||
const walk = (dir) => fs.existsSync(dir)
|
|
||||||
? fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
||||||
e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)])
|
|
||||||
: [];
|
|
||||||
const present = (p) => p === "test-cases" || p === "specs"
|
|
||||||
? walk(at(p)).some((f) => f.endsWith(".md"))
|
|
||||||
: fs.existsSync(at(p));
|
|
||||||
const mdFiles = walk(changeDir).filter((f) => f.endsWith(".md"));
|
|
||||||
const strip = (s) => (s ?? "").replace(/`/g, "").trim();
|
|
||||||
// Columns: fixed count, the last cell keeps any `|` it contains.
|
|
||||||
const tableRows = (text, heading, columns) => {
|
|
||||||
const section = text.split(/^## /m).find((s) => s.startsWith(heading));
|
|
||||||
if (!section) return null;
|
|
||||||
return section.split("\n").filter((l) => /^\|/.test(l)).slice(2).map((l) => {
|
|
||||||
const parts = l.replace(/^\|/, "").replace(/\|\s*$/, "").split("|");
|
|
||||||
return [...parts.slice(0, columns - 1), parts.slice(columns - 1).join("|")].map((c) => c.trim());
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
finishIfMissing();
|
|
||||||
const record = read(at("process-record.md"));
|
|
||||||
|
|
||||||
// 1. Unfilled templates.
|
|
||||||
for (const f of mdFiles) {
|
|
||||||
if (read(f).includes("<!-- guide:")) errors.push(`${rel(f)}: unfilled template guide comment remains`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Mode, class, Change Profile.
|
|
||||||
const labMode = (record.match(/\*\*Mode:\*\*\s*(lab|delivery)\b/i) || [])[1]?.toLowerCase();
|
|
||||||
if (!labMode) errors.push("process-record.md: **Mode:** must be `lab` or `delivery`");
|
|
||||||
const klass = (record.match(/\*\*Class:\*\*\s*(standard|complex)\b/i) || [])[1]?.toLowerCase();
|
|
||||||
if (!klass) errors.push("process-record.md: **Class:** must be `standard` or `complex`");
|
|
||||||
const complex = klass === "complex";
|
|
||||||
const hasG2 = labMode === "lab" || complex;
|
|
||||||
|
|
||||||
const profile = new Map();
|
|
||||||
for (const [rawId, selected, reason] of tableRows(record, "Change Profile", 3) ?? []) {
|
|
||||||
const id = strip(rawId);
|
|
||||||
if (!CONDITIONAL.includes(id)) { errors.push(`Change Profile: unknown artifact '${id}'`); continue; }
|
|
||||||
if (!/^(yes|no)$/i.test(strip(selected))) errors.push(`Change Profile: '${id}' Selected must be yes|no`);
|
|
||||||
if (!reason) errors.push(`Change Profile: '${id}' needs a trigger or reason`);
|
|
||||||
profile.set(id, /^yes$/i.test(strip(selected)));
|
|
||||||
}
|
|
||||||
const pending = [];
|
|
||||||
for (const id of CONDITIONAL) {
|
|
||||||
if (!profile.has(id)) { errors.push(`Change Profile: no decision for '${id}'`); continue; }
|
|
||||||
const has = present(PRIMARY[id]);
|
|
||||||
if (profile.get(id) && !has) {
|
|
||||||
const consumer = DOWNSTREAM[id].find(present);
|
|
||||||
if (consumer) errors.push(`'${id}' is selected but ${PRIMARY[id]} does not exist while ${consumer} was already written`);
|
|
||||||
else pending.push(id);
|
|
||||||
}
|
|
||||||
if (!profile.get(id) && has) errors.push(`'${id}' is not selected but ${PRIMARY[id]} exists; select it through a gate decision or remove the file`);
|
|
||||||
}
|
|
||||||
for (const id of SA_MODULES) {
|
|
||||||
if (profile.get(id) && !profile.get("system-analysis")) errors.push(`'${id}' requires system-analysis to be selected`);
|
|
||||||
}
|
|
||||||
if (fs.existsSync(at("system-analysis")) && !present(PRIMARY["system-analysis"])) {
|
|
||||||
errors.push("system-analysis/ exists without system-analysis/system-analysis.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Requirement references resolve; delta requirements are covered.
|
|
||||||
const skipSpecs = present(".openspec.yaml") && /^skip_specs:\s*true/m.test(read(at(".openspec.yaml")));
|
|
||||||
if (skipSpecs && present("specs")) errors.push("skip_specs: true but specs/ contains delta files");
|
|
||||||
const deltaReqs = [];
|
|
||||||
const known = new Map();
|
|
||||||
const removed = new Set();
|
|
||||||
const addKnown = (cap, h) => (known.get(cap) ?? known.set(cap, new Set()).get(cap)).add(h);
|
|
||||||
const key = (cap, req) => `${cap}\u0000${req}`;
|
|
||||||
for (const f of walk(at("specs")).filter((x) => x.endsWith("spec.md"))) {
|
|
||||||
const cap = path.relative(at("specs"), path.dirname(f)).split(path.sep).join("/");
|
|
||||||
let op = null;
|
|
||||||
const seen = new Set();
|
|
||||||
for (const line of read(f).split("\n")) {
|
|
||||||
op = (line.match(/^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements/) || [])[1] ?? op;
|
|
||||||
const req = (line.match(/^### Requirement:\s*(.+?)\s*$/) || [])[1];
|
|
||||||
if (req) {
|
|
||||||
if (seen.has(req)) errors.push(`specs/${cap}/spec.md: duplicate Requirement '${req}'`);
|
|
||||||
seen.add(req);
|
|
||||||
if (op === "REMOVED") removed.add(key(cap, req));
|
|
||||||
else addKnown(cap, req);
|
|
||||||
if (op === "ADDED" || op === "MODIFIED") deltaReqs.push(key(cap, req));
|
|
||||||
}
|
|
||||||
const to = (line.match(/^-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/) || [])[1];
|
|
||||||
if (to) addKnown(cap, to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const f of walk(path.join(root, "specs")).filter((x) => x.endsWith("spec.md"))) {
|
|
||||||
const cap = path.relative(path.join(root, "specs"), path.dirname(f)).split(path.sep).join("/");
|
|
||||||
for (const m of read(f).matchAll(/^### Requirement:\s*(.+?)\s*$/gm)) {
|
|
||||||
if (!removed.has(key(cap, m[1]))) addKnown(cap, m[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const refs = (text) => [...text.matchAll(REF)].map((m) => ({ cap: m[1], req: m[2] }));
|
|
||||||
for (const f of mdFiles.filter((x) => !rel(x).startsWith(`specs${path.sep}`))) {
|
|
||||||
for (const { cap, req } of refs(read(f))) {
|
|
||||||
if (!known.get(cap)?.has(req)) errors.push(`${rel(f)}: unknown requirement reference \`${cap}\` — \`${req}\``);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const tasksPath = at("tasks.md");
|
|
||||||
const tasksText = fs.existsSync(tasksPath) ? read(tasksPath) : "";
|
|
||||||
const coversLines = tasksText.split("\n").filter((l) => /\*\*Covers:\*\*/.test(l)).join("\n");
|
|
||||||
const taskCover = new Set(refs(coversLines).map((r) => key(r.cap, r.req)));
|
|
||||||
const qaText = [at("qa-analysis.md"), ...walk(at("test-cases"))].filter(fs.existsSync).map(read).join("\n");
|
|
||||||
const qaCover = new Set(refs(qaText).map((r) => key(r.cap, r.req)));
|
|
||||||
for (const k of deltaReqs) {
|
|
||||||
const label = k.replace("\u0000", "` — `");
|
|
||||||
if (tasksText && !taskCover.has(k)) errors.push(`tasks.md: delta requirement \`${label}\` has no task Covers`);
|
|
||||||
if (present("qa-analysis.md") && !qaCover.has(k)) warnings.push(`qa-analysis: delta requirement \`${label}\` has no verification reference (allowed only with a stated risk reason)`);
|
|
||||||
}
|
|
||||||
if (!skipSpecs && present("specs") && deltaReqs.length === 0) warnings.push("no ADDED/MODIFIED requirements found in delta specs");
|
|
||||||
|
|
||||||
// 4. Test case identity and traceability.
|
|
||||||
const tcIds = new Set();
|
|
||||||
for (const f of walk(at("test-cases")).filter((x) => x.endsWith(".md"))) {
|
|
||||||
for (const block of read(f).split(/^###\s+(?=TC-)/m).slice(1)) {
|
|
||||||
const id = block.split(/\s/)[0];
|
|
||||||
if (tcIds.has(id)) errors.push(`${rel(f)}: duplicate test case id ${id}`);
|
|
||||||
tcIds.add(id);
|
|
||||||
if (!refs(block).length) errors.push(`${rel(f)}: test case ${id} has no requirement reference`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Contracts and diagrams are referenced, JSON parses, required task kinds exist.
|
|
||||||
const saText = walk(at("system-analysis")).filter((f) => f.endsWith(".md")).map(read).join("\n");
|
|
||||||
const contracts = walk(at("system-analysis/contracts"));
|
|
||||||
for (const f of [...contracts, ...walk(at("system-analysis/diagrams"))]) {
|
|
||||||
if (!saText.includes(path.basename(f))) errors.push(`${rel(f)}: not referenced from any system-analysis/*.md`);
|
|
||||||
if (f.endsWith(".json")) {
|
|
||||||
try { JSON.parse(read(f)); } catch (e) { errors.push(`${rel(f)}: invalid JSON (${e.message})`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const taskLines = tasksText.split("\n").filter((l) => /^\s*[-*+]\s*\[.?\]/.test(l));
|
|
||||||
if (tasksText) {
|
|
||||||
if (!taskLines.some((l) => /Independent Review:/.test(l))) errors.push("tasks.md: no `Independent Review:` task");
|
|
||||||
if (contracts.length && !taskLines.some((l) => /Contract Parity:/.test(l))) errors.push("tasks.md: contracts exist but no `Contract Parity:` task");
|
|
||||||
if (profile.get("docs-impact") === false && !/^## Docs Impact/m.test(tasksText)) {
|
|
||||||
errors.push("tasks.md: docs-impact not selected, so a `## Docs Impact` section (changes or no-change reason) is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Gate Log: chronological; invalidating Gn withdraws Gn and every later gate.
|
|
||||||
const gates = new Map();
|
|
||||||
const order = [];
|
|
||||||
for (const [rawGate, , scope, validation, , decision] of tableRows(record, "Gate Log", 6) ?? []) {
|
|
||||||
const id = (strip(rawGate).match(/^G[1-4]\b/) || [])[0];
|
|
||||||
if (!id) continue;
|
|
||||||
if (/^invalidated\b/i.test(decision)) {
|
|
||||||
for (const g of [...gates.keys()]) if (g >= id) gates.delete(g);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!/^approved\b/i.test(decision)) continue;
|
|
||||||
if (!/"[^"]+"|«[^»]+»/.test(decision)) errors.push(`Gate ${id}: approval must quote the Change Owner decision`);
|
|
||||||
if (!validation) errors.push(`Gate ${id}: approval has no validation evidence`);
|
|
||||||
const prior = ["G1", "G2", "G3", "G4"].filter((g) => g < id && (g !== "G2" || hasG2));
|
|
||||||
if (prior.some((g) => !gates.has(g))) errors.push(`Gate ${id}: approved before ${prior.filter((g) => !gates.has(g)).join(", ")}`);
|
|
||||||
const ids = strip(scope).split(/[,\s]+/).filter(Boolean);
|
|
||||||
for (const a of ids) {
|
|
||||||
if (id !== "G4" && !allowedGates(a, complex, hasG2).includes(id)) errors.push(`Gate ${id}: '${a}' must be approved at ${allowedGates(a, complex, hasG2).join(" or ")}`);
|
|
||||||
}
|
|
||||||
gates.set(id, ids);
|
|
||||||
order.push(id);
|
|
||||||
}
|
|
||||||
if (gates.has("G1") && !gates.get("G1").includes("change-profile")) errors.push("Gate G1: scope must include change-profile");
|
|
||||||
|
|
||||||
// 7. Readiness.
|
|
||||||
const artifactPaths = [
|
|
||||||
["proposal", "proposal.md"], ["specs", "specs"], ["design", "design.md"],
|
|
||||||
["qa-analysis", "qa-analysis.md"], ["tasks", "tasks.md"],
|
|
||||||
...CONDITIONAL.map((id) => [id, PRIMARY[id]]),
|
|
||||||
];
|
|
||||||
const approvedScope = new Set([...gates.values()].flat());
|
|
||||||
const required = hasG2 ? ["G1", "G2", "G3"] : ["G1", "G3"];
|
|
||||||
const missingGates = required.filter((g) => !gates.has(g));
|
|
||||||
const unapproved = artifactPaths.filter(([id, p]) => !NOT_APPROVED.has(id) && present(p) && !approvedScope.has(id)).map(([id]) => id);
|
|
||||||
const blockers = [
|
|
||||||
...(tasksText ? [] : ["tasks.md missing"]),
|
|
||||||
...(missingGates.length ? [`missing gates: ${missingGates.join(", ")}`] : []),
|
|
||||||
...(unapproved.length ? [`not in any approved gate scope: ${unapproved.join(", ")}`] : []),
|
|
||||||
...(pending.length ? [`selected but missing: ${pending.join(", ")}`] : []),
|
|
||||||
];
|
|
||||||
notes.push(`apply-ready: ${blockers.length || errors.length ? `no (${[...blockers, ...(errors.length ? ["errors"] : [])].join("; ")})` : "yes"}`);
|
|
||||||
if (mode !== "plan") blockers.forEach((b) => errors.push(`apply blocker: ${b}`));
|
|
||||||
|
|
||||||
const sequence = ["proposal", "process-record", "research", "specs", "ux-design", "system-analysis",
|
|
||||||
...SA_MODULES, "security-analysis", "design", "qa-analysis", "test-cases", "docs-impact", "tasks"];
|
|
||||||
const pathOf = Object.fromEntries([...artifactPaths, ["process-record", "process-record.md"]]);
|
|
||||||
const next = sequence.find((id) => {
|
|
||||||
if (CONDITIONAL.includes(id) && !profile.get(id)) return false;
|
|
||||||
if (id === "specs" && skipSpecs) return false;
|
|
||||||
return !present(pathOf[id]);
|
|
||||||
});
|
|
||||||
notes.push(`next artifact: ${next ?? "none (planning complete for this profile)"}`);
|
|
||||||
|
|
||||||
// 8. Archive readiness. Native archive treats any non-[x] marker as open, so descoped work is
|
|
||||||
// removed from tasks.md by a gate decision and recorded in "Descoped Work".
|
|
||||||
if (mode === "archive") {
|
|
||||||
for (const l of taskLines) if (!/\[x\]/i.test(l)) errors.push(`tasks.md: open task: ${l.trim()}`);
|
|
||||||
for (const [task, reason, gate] of tableRows(record, "Descoped Work", 3) ?? []) {
|
|
||||||
if (task && (!reason || !/^G[1-4]$/.test(strip(gate)))) errors.push(`Descoped Work '${task}': needs reason and approving gate`);
|
|
||||||
}
|
|
||||||
if (!gates.has("G4")) errors.push("Gate G4 Delivery Acceptance is not approved");
|
|
||||||
if (labMode === "lab") {
|
|
||||||
const retro = record.split(/^## /m).find((s) => s.startsWith("Retrospective")) ?? "";
|
|
||||||
if (!retro.replace(/^Retrospective.*\n/, "").trim()) errors.push("process-record.md: Retrospective is empty (lab mode)");
|
|
||||||
if ((tableRows(record, "Lab Journal", 9) ?? []).length < gates.size) warnings.push("Lab Journal has fewer rows than approved gates");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finish();
|
|
||||||
|
|
||||||
function finishIfMissing() {
|
|
||||||
if (fs.existsSync(at("process-record.md"))) return;
|
|
||||||
errors.push("process-record.md is missing: Change Profile and Gate Log are required");
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish() {
|
|
||||||
for (const n of notes) console.log(`note: ${n}`);
|
|
||||||
for (const w of warnings) console.log(`warning: ${w}`);
|
|
||||||
for (const e of errors) console.log(`error: ${e}`);
|
|
||||||
console.log(errors.length ? `FAIL ${mode} ${changeName}: ${errors.length} error(s)` : `OK ${mode} ${changeName}`);
|
|
||||||
process.exit(errors.length ? 1 : 0);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
|||||||
|
# Spec Writing Profile
|
||||||
|
|
||||||
|
Профиль написания и ревью требований `wealth-ledger-sdd-lab`. Цель — одинаковое
|
||||||
|
понимание поведения разными авторами, разработчиками и QA, а не дословно одинаковые
|
||||||
|
документы. Это адаптация инженерных практик, не заявление о соответствии стандартам.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Автор и reviewer читают профиль перед подготовкой или согласованием Specs. Правила
|
||||||
|
применяются к новым и изменяемым требованиям; весь canonical store ради стиля не
|
||||||
|
переписывается. Пути, delta-операции и gates задаёт `schema.yaml`.
|
||||||
|
|
||||||
|
Specs определяют наблюдаемое поведение. Обоснования и открытые решения — в Proposal,
|
||||||
|
межсистемные детали — в `analysis/`, механизмы — в design, способы доказательства —
|
||||||
|
в QA. Ни один downstream-артефакт не вводит новое поведение молча.
|
||||||
|
|
||||||
|
## Writing Rules
|
||||||
|
|
||||||
|
1. **Основание.** Каждое обязательство следует из согласованного намерения или
|
||||||
|
существующего контракта. Наблюдение в коде, гипотеза и пример — не решение
|
||||||
|
владельца. Неизвестное запиши в Open Decisions: вопрос, кто решает, зависимое
|
||||||
|
требование и что блокируется. Несогласованные числа и варианты не становятся нормой.
|
||||||
|
Для спорного или выведенного при анализе Requirement перед первым Scenario добавь
|
||||||
|
`**Источник (ненормативно):**` — ссылку на конкретное решение Change Owner в Issue
|
||||||
|
или пункт согласованной редакции контракта. Ссылка должна оставаться пригодной из
|
||||||
|
canonical spec после archive; относительной ссылки на активный Proposal недостаточно.
|
||||||
|
Источник объясняет происхождение, но не заменяет полную норму или approval;
|
||||||
|
обоснование остаётся в Proposal.
|
||||||
|
2. **Термины.** Используй названия из затронутых canonical specs и предоставленного
|
||||||
|
проектного глоссария. Один термин — одно значение в названном контексте. «Счёт»,
|
||||||
|
«брокерский счёт» и «учётная запись» не взаимозаменяемы. Новый или спорный термин
|
||||||
|
определи в теле владеющего им Requirement; остальные требования ссылаются на него.
|
||||||
|
Определение должно различать случаи, а не заменять слово синонимом. Общие термины
|
||||||
|
передаются в глоссарий через Docs Impact; новый файл словаря на каждый change не нужен.
|
||||||
|
3. **Атомарность.** Один Requirement — одно независимо принимаемое обязательство.
|
||||||
|
Если одну часть можно принять, а другую отклонить — раздели. Связанные условия,
|
||||||
|
сохранность данных при отказе и единый атомарный результат остаются вместе.
|
||||||
|
Число союзов или SHALL не определяет атомарность.
|
||||||
|
4. **Явная норма.** Назови субъект, условие применимости и наблюдаемый результат.
|
||||||
|
Для нового обязательства предпочитай SHALL / SHALL NOT; существующие MUST /
|
||||||
|
MUST NOT равнозначны и сохраняются. SHOULD / MAY не заменяют неопределённый исход:
|
||||||
|
допустимая вариативность и исключения должны быть согласованы и описаны.
|
||||||
|
«Корректно», «быстро», «при необходимости», «и/или», «и т. д.» без определения —
|
||||||
|
повод уточнить правило. Краткость не оправдывает потерю условий.
|
||||||
|
5. **Величины и границы.** Для расчёта задай входы, формулу, знак, валюту, точность
|
||||||
|
и момент округления, если они влияют на результат. Для периода — включительность
|
||||||
|
границ, дату/время учёта и часовой пояс; для порога — сравнение и точку отсчёта.
|
||||||
|
Различай ноль, отсутствие значения и неизвестный результат. Не добавляй допуск,
|
||||||
|
таймаут или округление без источника только ради измеримости.
|
||||||
|
6. **Свобода реализации.** Описывай «что», а не внутренние классы, библиотеки или
|
||||||
|
алгоритм. Если внутренний механизм можно заменить без изменения внешнего
|
||||||
|
обязательства, это design. Внешний формат или протокол допустим в Specs, когда
|
||||||
|
именно он является согласованным обязательством, а не удобством реализации.
|
||||||
|
|
||||||
|
## Requirement and Scenario
|
||||||
|
|
||||||
|
Порядок блока: `### Requirement:` с устойчивым именем → общая норма → нужные
|
||||||
|
определения/таблица → `#### Scenario:`. Для новых требований используй короткое имя
|
||||||
|
на русском; существующие headings сохраняй для native delta-сопоставления.
|
||||||
|
|
||||||
|
Выбирай форму по смыслу (адаптация EARS, не дополнительный синтаксис OpenSpec):
|
||||||
|
|
||||||
|
| Смысл | Каркас нормы |
|
||||||
|
| --- | --- |
|
||||||
|
| Постоянное свойство | Система SHALL … |
|
||||||
|
| Реакция на событие | При … система SHALL … |
|
||||||
|
| Зависимость от состояния | Пока … система SHALL … |
|
||||||
|
| Ошибка или исключение | Если … система SHALL … |
|
||||||
|
|
||||||
|
У ADDED/MODIFIED Requirement минимум один Scenario. GIVEN нужен, когда исходное
|
||||||
|
состояние влияет на результат; WHEN задаёт событие; THEN — наблюдаемый исход с
|
||||||
|
определённым ожидаемым значением или составом, не «работает правильно». AND продолжает
|
||||||
|
предыдущий тип шага: дополняет исходный контекст, событие или ожидаемый исход.
|
||||||
|
Шаги не описывают внутренние вызовы и селекторы UI.
|
||||||
|
|
||||||
|
Сначала общее правило, затем различающие примеры: основной путь, существенная
|
||||||
|
граница, иной исход, отказ, повтор или конкуренция — по применимости. Каждый сценарий
|
||||||
|
добавляет новый класс поведения. Не вводи правило только в Scenario и не переписывай
|
||||||
|
правило целиком для каждой комбинации данных. Сценарии показывают следствия нормы,
|
||||||
|
но несколько примеров не доказывают универсальный инвариант.
|
||||||
|
|
||||||
|
## Representation
|
||||||
|
|
||||||
|
| Когда прозы недостаточно | Представление и обязательные уточнения |
|
||||||
|
| --- | --- |
|
||||||
|
| Взаимодействуют несколько условий | Таблица решений: область входов, смысл «любое», политика совпадений (одна строка / первая / все), результат отсутствия совпадений или объяснение полноты |
|
||||||
|
| Результат зависит от истории | Переходы: исходное состояние, событие/условие, новое состояние, видимый эффект; существенные повторы и недопустимые переходы |
|
||||||
|
| Должно сохраняться свойство | Инвариант/формула с областью действия и единицами; примеры нуля, знака и округления по применимости |
|
||||||
|
|
||||||
|
Нормативная таблица находится внутри Requirement до первого Scenario и вводится
|
||||||
|
фразой с SHALL. Она — единственное определение правила, а не копия соседней прозы.
|
||||||
|
Неоднозначные пересечения строк — дефект, если явно не задан приоритет или сбор
|
||||||
|
результатов. Таблица безусловно не нужна каждому требованию. Внутренние состояния
|
||||||
|
реализации и межсистемные схемы остаются в analysis/design.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Учебные примеры формы, не продуктовые решения WealthLedger. В реальном change
|
||||||
|
периоды, состояния и исходы берутся из его согласованных входов.
|
||||||
|
|
||||||
|
### Condition and Boundary
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отбор операций за календарный период
|
||||||
|
При формировании отчёта система SHALL включать все и только операции выбранного
|
||||||
|
счёта, дата учёта которых входит в выбранный период включительно по Europe/Moscow.
|
||||||
|
|
||||||
|
#### Scenario: Последний день периода и следующий день
|
||||||
|
- **GIVEN** выбран период 01.10.2026–31.10.2026 и счёт A
|
||||||
|
- **AND** на счёте A есть операции X с датой учёта 31.10.2026 и Y с датой 01.11.2026
|
||||||
|
- **WHEN** формируется отчёт
|
||||||
|
- **THEN** отчёт включает X и не включает Y
|
||||||
|
```
|
||||||
|
|
||||||
|
Плохо: «Система SHALL корректно отбирать операции до конца месяца» — не определены
|
||||||
|
месяц, границы, дата отбора и счёт. «THEN список корректен» не позволяет проверить исход.
|
||||||
|
|
||||||
|
### Decision Table
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Различимость неполного и пустого итога
|
||||||
|
Система SHALL представлять итог расчёта по таблице. «Полнота» означает получение
|
||||||
|
всех данных выбранной выборки; «есть записи» — наличие хотя бы одной записи.
|
||||||
|
Оба условия логические. Совпадает ровно одна строка; «любое» означает оба значения.
|
||||||
|
Покрыты все комбинации.
|
||||||
|
|
||||||
|
| Полнота подтверждена | Есть записи | Видимый итог |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Нет | Любое | Недоступен, без числового итога |
|
||||||
|
| Да | Нет | Ноль и пояснение пустого результата |
|
||||||
|
| Да | Да | Рассчитанный итог |
|
||||||
|
|
||||||
|
#### Scenario: Известная часть не становится окончательной суммой
|
||||||
|
- **GIVEN** известна одна запись на 100 RUB, но полнота выборки не подтверждена
|
||||||
|
- **WHEN** пользователь просматривает результат
|
||||||
|
- **THEN** итог обозначен как недоступный, не показан ни как 100 RUB, ни как ноль
|
||||||
|
```
|
||||||
|
|
||||||
|
Плохо: строки «нет данных → 0» и «данные не полны → недоступно» без определения
|
||||||
|
«нет данных» — одному входу могут соответствовать два разных результата.
|
||||||
|
|
||||||
|
### State Transition
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отмена формирования отчёта
|
||||||
|
При запросе отмены система SHALL изменять состояние формирования отчёта по таблице.
|
||||||
|
Область правила — состояния «Ожидает», «Отменён», «Готов»; начальное — «Ожидает».
|
||||||
|
Совпадает ровно одна строка. Таблица определяет только событие отмены.
|
||||||
|
|
||||||
|
| До | После | Результат отмены |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ожидает | Отменён | Отмена подтверждена, отчёт не будет сформирован |
|
||||||
|
| Отменён | Отменён | Отмена подтверждена без повторного эффекта |
|
||||||
|
| Готов | Готов | Отмена отклонена, готовый отчёт сохранён |
|
||||||
|
|
||||||
|
#### Scenario: Повтор отмены
|
||||||
|
- **GIVEN** формирование отчёта уже отменено
|
||||||
|
- **WHEN** пользователь снова запрашивает отмену
|
||||||
|
- **THEN** отмена подтверждена, состояние остаётся «Отменён»
|
||||||
|
```
|
||||||
|
|
||||||
|
Плохо: «повтор ничего не делает» — не определён результат для пользователя.
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
Используй два прохода на существующем gate Specs (G2 или G3), не новый gate.
|
||||||
|
Автор выполняет самопроверку; назначенный по режиму reviewer перепроверяет независимо.
|
||||||
|
|
||||||
|
| Проход | Что проверить |
|
||||||
|
| --- | --- |
|
||||||
|
| Нормоконтроль | Native headings/операции; термины и единицы; явные субъект/условие/исход; атомарность; корректные ссылки; Scenario с проверяемым THEN |
|
||||||
|
| Смысл и набор | Необходимость и происхождение норм (у спорных/выведенных — источник действительно подтверждает норму, ссылка пригодна после archive); основной путь, альтернативы, отказы и границы; повторы/конкуренция/сохранность по риску; непротиворечивость таблиц и итогового baseline; отсутствие выдуманных решений |
|
||||||
|
|
||||||
|
Проверь не только delta, но и получающийся набор вместе с canonical specs:
|
||||||
|
сохранились ли действующие ограничения, исключения, инварианты и сценарии? MODIFIED
|
||||||
|
заменяет полный блок; редакторское улучшение не разрешает удалить прежний сценарий
|
||||||
|
или незаметно переименовать Requirement.
|
||||||
|
|
||||||
|
Профиль не отменяет ограничений установленного CLI. При предупреждении о длине
|
||||||
|
сократи повторения или раздели действительно независимые обязательства; не убирай
|
||||||
|
условия и не прячь норму в комментарий/код ради успешного `--strict`.
|
||||||
|
|
||||||
|
Контрольный вопрос: «Назови существенную ошибочную реализацию и наблюдение, которым
|
||||||
|
это требование отличит её от соответствующей». Если ответ зависит от догадки,
|
||||||
|
укажи точную неопределённость. Это эвристика ревью, не доказательство полноты.
|
||||||
|
|
||||||
|
Finding: **файл/heading + цитата → нарушенное правило → два возможных толкования
|
||||||
|
или недостающий исход → вопрос владельцу/адресат исправления**. Reviewer возвращает
|
||||||
|
находки, не переписывает текст по вкусу. Пример: «до конца месяца» → неясна правая
|
||||||
|
граница → операция в последний день включается или исключается → уточнить в Specs.
|
||||||
|
Нерешённый вопрос, меняющий требование или ожидаемый результат, блокирует его gate;
|
||||||
|
косметическое предпочтение не блокирует. Validation и Findings — в существующем
|
||||||
|
Gate Log, без нового отчёта на каждый Requirement.
|
||||||
|
|
||||||
|
### Trial
|
||||||
|
|
||||||
|
Эффект профиля проверяется сравнительным пилотом; до результатов он не подтверждён.
|
||||||
|
|
||||||
|
1. До запуска зафиксируй четыре одинаковых для обеих версий задания: простое правило,
|
||||||
|
ветвление, повтор/состояние и MODIFIED с исходной canonical spec. Для оценки отдельно
|
||||||
|
запиши согласованные исходы, намеренно оставленные неизвестные и контрольные вопросы.
|
||||||
|
2. Сравни прежнюю и изменённую инструкции минимум на двух авторах/моделях, каждый
|
||||||
|
выполняет обе версии. Сохрани входы, prompts, версии инструкций и моделей, доступные
|
||||||
|
настройки и результаты. Для LLM используй отдельные сессии без ответов другой версии;
|
||||||
|
для людей чередуй порядок версий, чтобы уменьшить эффект обучения.
|
||||||
|
3. Независимые читатели отвечают на одинаковые контрольные вопросы по полученным
|
||||||
|
спецификациям, не зная автора и версии инструкции. Ответы для оценки из шага 1
|
||||||
|
раскрываются после чтения; согласие читателей само по себе не означает правильность.
|
||||||
|
4. По каждому заданию сравни расхождения трактовок, ошибки относительно согласованных
|
||||||
|
исходов, пропущенные случаи, выдуманные нормы и время уточнения/ревью. Сохрани
|
||||||
|
конкретные примеры ошибок вместе с результатами в Issue пилота.
|
||||||
|
5. Критерий улучшения согласуй до запуска: меньше смысловых расхождений без роста
|
||||||
|
ошибок, пропусков и выдуманных норм; допустимое изменение времени определяет владелец.
|
||||||
|
Если различий нет или данных недостаточно, эффект не подтверждён. Вывод ограничен
|
||||||
|
проверенными заданиями и авторами/моделями. Совпадение формулировок и успешный CLI
|
||||||
|
не считаются доказательством одинакового понимания.
|
||||||
|
|
||||||
|
## Sources and Adaptation
|
||||||
|
|
||||||
|
Из девяти исследований `sdd-spec-quality/*.md` и трёх сводок `result/*.pdf` выбраны
|
||||||
|
правила с понятной пользой для ручного Markdown-процесса. Первоисточники проверены
|
||||||
|
30.10.2026; критерии адаптированы, а не перенесены как обязательная сертификация.
|
||||||
|
|
||||||
|
| Источник | Что взято и где предел |
|
||||||
|
| --- | --- |
|
||||||
|
| [NASA SE Handbook, Appendix C, C.2–C.4](https://www.nasa.gov/reference/appendix-c-how-to-write-a-good-requirement/) | Необходимость, одна мысль, «что, а не как», проверяемость и согласованность; не сертификационный комплект документов |
|
||||||
|
| [INCOSE GtWR v4 Summary, с. 2–3](https://www.incose.org/wp-content/uploads/legacy/working-groups/requirements-wg/guidetowritingrequirements/incose_rwg_gtwr_v4_summary_sheet.pdf) | C1/C3/C5/C7 для отдельного требования, C10–C14 для набора; R4/R7/R33–R36 — термины, величины, время. Полный платный Guide не заявлен как проверенный |
|
||||||
|
| [EARS, автор методики](https://alistairmavin.com/ears/) | Каркасы условия/события/состояния/ошибки; русский язык — адаптация, не буквальная английская грамматика |
|
||||||
|
| [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html), [8174 §2](https://www.rfc-editor.org/rfc/rfc8174.html#section-2) | Значения uppercase SHALL/MUST и запретов; наличие ключевого слова само по себе не гарантирует качество |
|
||||||
|
| [Cucumber, Given/When/Then](https://cucumber.io/docs/gherkin/reference/) | Исходное состояние, событие, наблюдаемый исход; Markdown Scenario не становится исполняемым Gherkin-тестом |
|
||||||
|
|
||||||
|
Из предложений не приняты: жёсткие лимиты слов/сценариев, подсчёт SHALL вместо
|
||||||
|
атомарности, обязательные REQ-ID и скрытые атрибуты, JSON-first, новые линтеры и gates.
|
||||||
|
Из нормоконтроля конструкторской документации заимствовано разделение проверки формы
|
||||||
|
и смысла; соответствие ГОСТ, DO-178C или ISO/IEC/IEEE 29148 не заявляется. Таблицы и
|
||||||
|
инварианты используются по риску; обязательная формальная модель каждому change не нужна.
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
# Data Mapping
|
# Data Mapping
|
||||||
|
|
||||||
<!-- guide: Field-level transformation between systems. Large mappings go to a
|
<!-- Преобразование данных между системами по полям. Большой mapping — машиночитаемый
|
||||||
machine-checkable file in contracts/ and are referenced here. Remove guides. -->
|
файл в contracts/ со ссылкой отсюда. -->
|
||||||
|
|
||||||
## Source and Target
|
## Source and Target
|
||||||
|
|
||||||
| Mapping | Source (owner) | Target (owner) | Direction | Trigger |
|
| Mapping | Источник (владелец) | Цель (владелец) | Направление | Trigger |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Field Mapping
|
## Field Mapping
|
||||||
|
|
||||||
| Target field | Source field(s) | Transformation | Unit / currency / scale | Nullability | Default | Validation | On failure |
|
| Поле цели | Поля источника | Преобразование | Единица / валюта / scale | Nullability | Default | Валидация | При ошибке |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
<!-- guide: Conservation rules that must hold after mapping, e.g. "total = sum of
|
<!-- Правила сохранения после mapping, например «итог = сумма позиций + кэш; каждая
|
||||||
positions + cash; each money unit counted once", sign conventions, rounding
|
денежная единица учтена один раз», знаки, точка округления. Каждый инвариант
|
||||||
point. Each invariant references the spec requirement it protects. -->
|
ссылается на защищаемый Requirement. -->
|
||||||
|
|
||||||
## Double-counting and Classification Risks
|
## Double-counting and Classification Risks
|
||||||
|
|
||||||
<!-- guide: Every source record type that could fall into two target aggregates,
|
<!-- Каждый тип исходной записи, который может попасть в два агрегата, и правило,
|
||||||
and the rule that assigns it to exactly one. -->
|
относящее его ровно к одному. -->
|
||||||
|
|
||||||
## Transformation Errors
|
## Transformation Errors
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
# Engineering Design
|
# Engineering Design
|
||||||
|
|
||||||
<!-- guide: How affected repositories implement approved specs and system
|
<!-- Как affected repositories реализуют утверждённые specs и системные обязательства.
|
||||||
obligations. Reference upstream ids; do not restate them. Remove guides. -->
|
Ссылайся на upstream ids, не пересказывай. -->
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
<!-- guide: Exact upstream artifacts this design implements (specs, selected
|
<!-- Точные upstream-артефакты, которые реализует этот design (specs, выбранные
|
||||||
system-analysis modules, ux-design, security-analysis). -->
|
модули analysis/, ux-design, security-analysis). -->
|
||||||
|
|
||||||
## Architecture Drivers
|
## Architecture Drivers
|
||||||
|
|
||||||
<!-- guide: Requirements, NFR ids, constraints and risks that force decisions. -->
|
<!-- Requirements, ids NFR, ограничения и риски, которые вынуждают решения. -->
|
||||||
|
|
||||||
## Technical Goals / Non-Goals
|
## Technical Goals / Non-Goals
|
||||||
|
|
||||||
## Implementation Impact Map
|
## Implementation Impact Map
|
||||||
|
|
||||||
| Area | Affected | Outcome or N/A reason |
|
| Область | Затронута | Результат или причина N/A |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| main | | |
|
| main | | |
|
||||||
| frontend | | |
|
| frontend | | |
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
<!-- guide: Per decision: context, chosen option, rejected alternatives, driver it
|
<!-- На каждое решение: контекст, выбранный вариант, отвергнутые альтернативы, driver.
|
||||||
satisfies. Group by repository when that helps. -->
|
Группируй по repository, если это помогает. -->
|
||||||
|
|
||||||
## Components and Responsibilities
|
## Components and Responsibilities
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@
|
|||||||
|
|
||||||
## Contract Implementation
|
## Contract Implementation
|
||||||
|
|
||||||
<!-- guide: How each contract is produced/consumed and how published OpenAPI is
|
<!-- Как каждый контракт производится/потребляется и как опубликованный OpenAPI
|
||||||
compared with the target contract. N/A when no contracts changed. -->
|
сравнивается с target-контрактом. N/A, если контракты не менялись. -->
|
||||||
|
|
||||||
## Cross-cutting Implementation
|
## Cross-cutting Implementation
|
||||||
|
|
||||||
## UI Implementation
|
## UI Implementation
|
||||||
|
|
||||||
<!-- guide: With ux-design.md: component composition, state, data fetching, how
|
<!-- С ux-design: композиция компонентов, state, data fetching, как получается каждое
|
||||||
each UI state is produced — no restated UX. Without ux-design.md: state
|
UI-состояние — без пересказа UX. Без ux-design: new/changed/reuse или N/A с
|
||||||
new/changed, reuse or N/A with reason, plus the needed screen/state detail. -->
|
причиной и нужные детали экранов/состояний. -->
|
||||||
|
|
||||||
## Developer Verification Strategy
|
## Developer Verification Strategy
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
|
|
||||||
## Solution Assessment
|
## Solution Assessment
|
||||||
|
|
||||||
<!-- guide: Required for new auth, security, storage, integration, observability,
|
<!-- Обязателен для нового auth, security, storage, интеграции, observability,
|
||||||
caching, concurrency or shared tooling; otherwise N/A with reason. -->
|
кеширования, конкурентности или общего tooling; иначе N/A с причиной. -->
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Documentation Impact
|
# Documentation Impact
|
||||||
|
|
||||||
<!-- guide: Plan only; docs/ is edited by [docs] tasks. Remove guides. -->
|
<!-- Только план; docs/ правят tasks `[docs]`. -->
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
## Documentation Changes
|
## Documentation Changes
|
||||||
|
|
||||||
| Action | Target | Category | Resulting AS-IS | Source | Verification |
|
| Action | Target | Category | Итоговое AS-IS | Source | Verification |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Diagram Promotion
|
## Diagram Promotion
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
# Failure Model
|
# Failure Model
|
||||||
|
|
||||||
<!-- guide: Boundary-level failure semantics. Implementation of mechanisms (queues,
|
<!-- Семантика отказов на границах. Реализация механизмов (очереди, locks,
|
||||||
locks, libraries) belongs to design.md. Remove guides before G3. -->
|
библиотеки) — в design.md. -->
|
||||||
|
|
||||||
## Failure Scenarios
|
## Failure Scenarios
|
||||||
|
|
||||||
| ID | Boundary / step | Failure | Detection | Required system behavior | Observable result | Requirement |
|
| ID | Граница / шаг | Отказ | Обнаружение | Требуемое поведение системы | Наблюдаемый результат | Requirement |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Timeouts and Retries
|
## Timeouts and Retries
|
||||||
|
|
||||||
## Idempotency and Duplicate Handling
|
## Idempotency and Duplicate Handling
|
||||||
|
|
||||||
<!-- guide: Idempotency key, scope, retention; what a duplicate request/event
|
<!-- Ключ идемпотентности, scope, срок хранения; что даёт повторный запрос/событие. -->
|
||||||
yields. -->
|
|
||||||
|
|
||||||
## Ordering and Concurrency
|
## Ordering and Concurrency
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# Non-Functional Requirements
|
# Non-Functional Requirements
|
||||||
|
|
||||||
<!-- guide: Only measurable constraints that change design or verification.
|
<!-- Только измеримые ограничения, которые меняют design или проверку. У каждой
|
||||||
Each row needs a threshold and a way to measure it. Remove guides. -->
|
строки есть согласованный порог с единицами, условия/нагрузка и способ измерения.
|
||||||
|
Для времени укажи точки начала/конца, для статистики — окно и агрегат.
|
||||||
|
Не превращай пример числа из исследования в утверждённую цель. -->
|
||||||
|
|
||||||
| ID | Category | Requirement | Threshold | Measurement / evidence | Rationale / source |
|
| ID | Категория | Требование | Порог и единицы | Условия измерения | Измерение / evidence | Обоснование / источник |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Constraints Without Thresholds
|
## Constraints Without Thresholds
|
||||||
|
|
||||||
<!-- guide: Constraints that are real but not measurable yet; state why and who
|
<!-- Реальные, но пока неизмеримые ограничения: почему нет порога, кто решит и какое
|
||||||
decides the threshold. -->
|
решение/проверку это блокирует. Не выдавай неизвестное за согласованный NFR. -->
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# Observability
|
# Observability
|
||||||
|
|
||||||
<!-- guide: What must be observable and why; not which SDK, exporter or logger
|
<!-- Что должно быть наблюдаемо и зачем; не какой SDK, exporter или logger (design.md). -->
|
||||||
(design.md). Remove guides before G3. -->
|
|
||||||
|
|
||||||
## Semantic Events
|
## Semantic Events
|
||||||
|
|
||||||
| Event | Trigger | Required attributes | Consumer / purpose |
|
| Событие | Trigger | Обязательные атрибуты | Потребитель / назначение |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
||||||
## Logs, Metrics and Traces
|
## Logs, Metrics and Traces
|
||||||
@@ -14,11 +13,11 @@
|
|||||||
|
|
||||||
## SLI and Alert Conditions
|
## SLI and Alert Conditions
|
||||||
|
|
||||||
| SLI | Definition | Target | Alert condition |
|
| SLI | Определение | Цель | Условие алерта |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
||||||
## Diagnostic Scenarios
|
## Diagnostic Scenarios
|
||||||
|
|
||||||
<!-- guide: "When X happens, an operator can determine Y from Z." -->
|
<!-- «Когда X, оператор определяет Y по Z». -->
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
# Process Record
|
# Process Record
|
||||||
|
|
||||||
**Mode:** <!-- guide: lab | delivery -->
|
**Mode:** <!-- lab | delivery -->
|
||||||
**Class:** <!-- guide: standard | complex -->
|
**Class:** <!-- standard | complex -->
|
||||||
**Issue:** <!-- guide: Gitea Issue link -->
|
**Issue:** <!-- ссылка на Gitea Issue -->
|
||||||
|
|
||||||
## Change Profile
|
## Change Profile
|
||||||
|
|
||||||
<!-- guide: One row per conditional artifact, all ten rows required. Selected =
|
<!-- Строка на каждый условный артефакт, все десять обязательны. Выбран = yes|no.
|
||||||
yes|no. Reason cites the concrete trigger from the instruction table or why
|
Причина называет конкретный trigger из instruction артефакта или объясняет, почему
|
||||||
it does not apply; "lab practice" is a valid reason only in lab mode and
|
он не применим; «lab: практика …» допустима только в lab и называет практику.
|
||||||
must name the practice. -->
|
Пример:
|
||||||
|
| failure-model | yes | синхронизация брокера повторяет неидемпотентную запись |
|
||||||
|
| nfr | no | нет новых обязательств по задержке, объёму или хранению |
|
||||||
|
| data-mapping | yes | lab: практика data mapping |
|
||||||
|
Плохо: `| nfr | no | N/A |` — причина должна объяснять, почему не нужно. -->
|
||||||
|
|
||||||
| Artifact | Selected | Reason |
|
| Артефакт | Выбран | Причина |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| research | | |
|
| research | | |
|
||||||
| ux-design | | |
|
| ux-design | | |
|
||||||
@@ -26,32 +30,40 @@
|
|||||||
|
|
||||||
## Gate Log
|
## Gate Log
|
||||||
|
|
||||||
<!-- guide: Append-only, chronological. Scope = artifact ids covered by the
|
<!-- Только дописывание, по хронологии. Scope — ids артефактов в решении.
|
||||||
decision. Validation = commands run and their results, review verdicts.
|
Validation — выполненные команды и результаты, вердикты review.
|
||||||
Decision = `approved "<quoted owner words>"`, `rejected`, `changes requested`
|
Decision — `approved "<дословные слова владельца>"`, `rejected`,
|
||||||
or `invalidated: <reason>`. Never write a decision the owner did not give. -->
|
`changes requested` или `invalidated: <причина>`. Никогда не записывай решение,
|
||||||
|
которого владелец не давал. ПОСЛЕДНЯЯ строка gate — его состояние; более поздняя
|
||||||
|
rejected / changes requested / invalidated снимает этот gate и все последующие.
|
||||||
|
Для Specs: Validation отмечает нормоконтроль и смысловую проверку набора по
|
||||||
|
spec-writing.md; Findings — конкретные неясности/пропуски и их разрешение.
|
||||||
|
Пример:
|
||||||
|
| G1 | 2026-10-29 | proposal, change-profile | `openspec validate` OK; readiness: plan OK | product-analyst: 1 major — закрыт в Open Decisions | approved "ок, идём дальше" (сессия) |
|
||||||
|
| G2 | 2026-10-30 | specs | `openspec validate --strict` OK; readiness: plan OK | qa-engineer: в Scenario нет значения THEN | changes requested |
|
||||||
|
Плохо: `approved` без цитаты владельца; строка G4 со Scope `design`. -->
|
||||||
|
|
||||||
| Gate | Date | Scope | Validation | Findings | Decision |
|
| Gate | Дата | Scope | Validation | Findings | Decision |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Descoped Work
|
## Descoped Work
|
||||||
|
|
||||||
<!-- guide: Tasks removed from tasks.md after G3. Reason and approving gate are
|
<!-- Tasks, удалённые из tasks.md после G3. Причина и gate решения обязательны.
|
||||||
required. Leave the header row only when nothing was descoped. -->
|
Если ничего не снято — только строка заголовка. -->
|
||||||
|
|
||||||
| Task | Reason | Gate |
|
| Task | Причина | Gate |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
||||||
## Lab Journal
|
## Lab Journal
|
||||||
|
|
||||||
<!-- guide: lab mode: one row per completed stage, written right after it.
|
<!-- lab: строка на каждую завершённую стадию, сразу после неё. Уникальные находки —
|
||||||
Unique findings = what this stage found that no other stage did.
|
то, что нашла только эта стадия. Стоимость — вызовы сабагентов, примерно токены
|
||||||
Cost = subagent calls, approximate tokens or time. delivery mode: optional. -->
|
или время. delivery: необязательно. -->
|
||||||
|
|
||||||
| Stage | Role / subagent | Unique findings | Decisions | Risks found / excluded | Handoff issues | Skills | Cost | Transferable |
|
| Стадия | Роль / сабагент | Уникальные находки | Решения | Риски найдены / исключены | Проблемы передачи | Skills | Стоимость | Переносимо |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Retrospective
|
## Retrospective
|
||||||
|
|
||||||
<!-- guide: Written after G4. Which stages paid off, which were redundant for this
|
<!-- После G4. Какие стадии окупились, какие были лишними для этого класса change,
|
||||||
class of change, what to change in the schema. Facts first, then opinion. -->
|
что поменять в schema. Сначала факты, потом мнение. -->
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Proposal
|
# Proposal
|
||||||
|
|
||||||
<!-- guide: Language of the Change Owner. No files, APIs, tables, libraries,
|
<!-- Язык Change Owner. Без файлов, API, таблиц, библиотек, диаграмм и тест-планов. -->
|
||||||
diagrams or test plans. Remove every guide comment before G1. -->
|
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -23,14 +22,18 @@
|
|||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
|
<!-- Каждый критерий измерим или наблюдаем: что увидит пользователь или владелец. -->
|
||||||
|
|
||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
<!-- guide: Facts that justify the change, each with a source: Issue, research
|
<!-- Факты, обосновывающие change, каждый с источником: Issue, research (ссылка или
|
||||||
(link or `research.md`), incident, measurement. Mark hypotheses as such. -->
|
`analysis/research.md`), инцидент, измерение. Гипотезы помечай явно. -->
|
||||||
|
|
||||||
## Assumptions / Dependencies
|
## Assumptions / Dependencies
|
||||||
|
|
||||||
## Open Decisions
|
## Open Decisions
|
||||||
|
|
||||||
<!-- guide: Decisions not yet taken that may change scope. Each item: question,
|
<!-- Ещё не принятые решения, способные изменить scope. Каждое: вопрос, кто решает,
|
||||||
who decides, what blocks until it is answered. Empty only if none exist. -->
|
зависимое требование/артефакт и что блокируется до ответа. Несогласованное
|
||||||
|
правило, термин, порог или вариант поведения не заменяй допущением в Specs.
|
||||||
|
Пусто, только если таких вопросов нет. -->
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
# QA Analysis
|
# QA Analysis
|
||||||
|
|
||||||
<!-- guide: Risk-based system verification strategy. Reference scenarios; do not
|
<!-- Risk-based стратегия системной проверки. Ссылайся на Scenarios, не пересказывай.
|
||||||
restate them. No developer unit/component test plans. Remove guides. -->
|
Без планов developer unit/component tests. -->
|
||||||
|
|
||||||
## Risk Assessment
|
## Risk Assessment
|
||||||
|
|
||||||
| Risk | Affected requirements | Likelihood | Impact | Verification depth |
|
| Риск | Затронутые требования | Вероятность | Ущерб | Глубина проверки |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Coverage Matrix
|
## Coverage Matrix
|
||||||
|
|
||||||
<!-- guide: One row per delta requirement. Check = system-level way of proving
|
<!-- Строка на каждый ADDED/MODIFIED/REMOVED Requirement. Назови покрытые Scenarios
|
||||||
it; test case ids when test-cases is selected; or "not verified: <risk
|
точными headings и ветви таблицы/переходы, если они есть. Проверка даёт наблюдение,
|
||||||
reason>". -->
|
отличающее исполнение нормы от нарушения; test cases — ссылками, не копиями.
|
||||||
|
Пропуск случая — «not verified: <риск-причина>» с названием случая.
|
||||||
|
Покрытие — только строки этой таблицы; упоминание требования в другом месте
|
||||||
|
файла покрытием не считается. REMOVED требует проверки миграции/регрессии.
|
||||||
|
При skip_specs: true вместо дельт укажи существующее поведение/ограничение,
|
||||||
|
которое сохраняем, точный источник и регрессионную проверку.
|
||||||
|
Пример:
|
||||||
|
| `example-report` — `Отбор операций за календарный период` | Последний день периода и следующий день | Отчёт за октябрь включает X за 31.10 и исключает Y за 01.11 | API | ссылка на Scenario | -->
|
||||||
|
|
||||||
| Requirement | Check | Level | Test cases |
|
| Requirement / регрессионный baseline | Сценарии / ветви правила | Проверка / ожидаемое наблюдение | Уровень | Test cases |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Negative and Boundary Focus
|
## Negative and Boundary Focus
|
||||||
|
|
||||||
|
<!-- Границы до/на/после порога, отказ, повтор, конкуренция, сохранность данных —
|
||||||
|
только применимое. Ссылайся на нормы, не придумывай исходы. -->
|
||||||
|
|
||||||
## Cross-system Verification
|
## Cross-system Verification
|
||||||
|
|
||||||
## Test Data and Environment Needs
|
## Test Data and Environment Needs
|
||||||
@@ -31,4 +41,5 @@
|
|||||||
|
|
||||||
## Gaps / Blockers
|
## Gaps / Blockers
|
||||||
|
|
||||||
<!-- guide: Each gap names its routing destination artifact. -->
|
<!-- Цитата/heading → неоднозначность или непокрытый исход → владелец решения и
|
||||||
|
upstream-артефакт. Вопрос о значении expected result блокирует согласование. -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Product Research
|
# Product Research
|
||||||
|
|
||||||
<!-- guide: Input context, not a requirement source. Every claim is marked
|
<!-- Входной контекст, не источник требований. Каждое утверждение помечено:
|
||||||
[F] fact with source, [I] interpretation or [H] hypothesis. Remove guides. -->
|
[F] факт с источником, [I] интерпретация, [H] гипотеза. -->
|
||||||
|
|
||||||
## Problem Statement
|
## Problem Statement
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## Existing Solutions and Alternatives
|
## Existing Solutions and Alternatives
|
||||||
|
|
||||||
| Option | What it does | Strengths | Weaknesses | Source |
|
| Вариант | Что делает | Сильные стороны | Слабые стороны | Источник |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Evidence & Sources
|
## Evidence & Sources
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
|
|
||||||
<!-- guide: build / do not build / narrow scope / research more, with reasons.
|
<!-- делать / не делать / сузить scope / исследовать дальше — с причинами.
|
||||||
"Do not build" is a valid outcome. -->
|
«Не делать» — допустимый итог. -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Security Analysis
|
# Security Analysis
|
||||||
|
|
||||||
<!-- guide: Threat model of this change only. Reference security NFR rows by id;
|
<!-- Threat model только этого change. На security-NFR ссылайся по id, не
|
||||||
do not restate them. No generic checklist. Remove guides before G3. -->
|
пересказывай. Без универсального чек-листа. -->
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -11,16 +11,16 @@
|
|||||||
|
|
||||||
## Threats
|
## Threats
|
||||||
|
|
||||||
| ID | Threat (STRIDE) | Attack scenario | Asset / boundary | Existing control | Likelihood / impact |
|
| ID | Угроза (STRIDE) | Сценарий атаки | Актив / граница | Существующий контроль | Вероятность / ущерб |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Recommended Mitigations
|
## Recommended Mitigations
|
||||||
|
|
||||||
| Threat | Mitigation | Owner artifact (specs / system-analysis / design) |
|
| Угроза | Mitigation | Артефакт-владелец (specs / system-analysis / design) |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
||||||
## Residual Risks
|
## Residual Risks
|
||||||
|
|
||||||
## Validation Requirements
|
## Validation Requirements
|
||||||
|
|
||||||
<!-- guide: Evidence needed before G4: review focus, negative tests, tooling. -->
|
<!-- Evidence, нужные до G4: фокус review, негативные тесты, инструменты. -->
|
||||||
|
|||||||
@@ -1,18 +1,48 @@
|
|||||||
<!--
|
<!--
|
||||||
Choose only the sections this delta needs; delete this guide before approval.
|
Перед заполнением прочитай единый профиль:
|
||||||
|
openspec/schemas/wealth-ledger-sdd-lab/spec-writing.md.
|
||||||
|
Оставь только нужные этой дельте разделы; подсказки и образец не входят в результат.
|
||||||
|
Файл — specs/<capability-path>/spec.md с точным путём из Proposal.
|
||||||
|
|
||||||
New capability only: add `## Purpose` with 1–2 sentences (50+ characters).
|
Только новая capability: `## Purpose` в 1–2 предложения (от 50 символов).
|
||||||
Existing capability: do not add Purpose.
|
Существующая capability: Purpose не добавляй.
|
||||||
|
|
||||||
Supported operations:
|
Операции:
|
||||||
- `## ADDED Requirements`: new complete Requirement blocks.
|
- `## ADDED Requirements` — новые полные блоки Requirement.
|
||||||
- `## MODIFIED Requirements`: full existing Requirement blocks with every
|
- `## MODIFIED Requirements` — полный существующий блок со всеми сохраняемыми
|
||||||
surviving Scenario, then edited behavior; heading matches exactly.
|
Scenario, затем изменённое поведение; heading совпадает точно.
|
||||||
- `## REMOVED Requirements`: Requirement heading plus **Reason** and **Migration**.
|
- `## REMOVED Requirements` — heading Requirement плюс **Reason** и **Migration**.
|
||||||
- `## RENAMED Requirements`: `FROM:` and `TO:` heading pairs only.
|
- `## RENAMED Requirements` — только пары `FROM:` и `TO:`.
|
||||||
|
|
||||||
Every normative Requirement uses BCP 14 (RFC 2119 + RFC 8174) uppercase
|
Каркас ADDED/MODIFIED: устойчивое имя → субъект SHALL <наблюдаемый результат>
|
||||||
SHALL/MUST deliberately, and at least one `#### Scenario:` with `- **WHEN**`
|
с явным условием, если оно нужно → определения/таблица → Scenarios.
|
||||||
and `- **THEN**`. Lowercase must/should/may are ordinary language.
|
SHALL NOT — запрет; существующие MUST/MUST NOT равнозначны. Один Requirement —
|
||||||
Do not leave empty operation sections or a sample Requirement.
|
одно независимо принимаемое обязательство, не подсчёт предложений или SHALL.
|
||||||
|
Сценарий: GIVEN — существенное исходное состояние, WHEN — событие,
|
||||||
|
THEN — конкретный наблюдаемый исход. Минимум один Scenario; границы/отказы/повторы
|
||||||
|
добавляй по смыслу. Новое правило в одном лишь сценарии недопустимо.
|
||||||
|
AND дополняет предыдущий тип шага GIVEN/WHEN/THEN.
|
||||||
|
Термины определены, величины имеют единицы и границы. Неизвестное — вопрос в
|
||||||
|
Open Decisions с владельцем и зависимым требованием, не придуманный ответ.
|
||||||
|
|
||||||
|
Для спорного/выведенного Requirement укажи источник перед первым Scenario по профилю.
|
||||||
|
Пример формата (подставь реальное согласованное решение):
|
||||||
|
**Источник (ненормативно):** [решение владельца](<URL-комментария-Issue>), пункт «Границы периода».
|
||||||
|
Ссылка остаётся пригодной после archive; норму полностью сформулируй в Requirement.
|
||||||
|
|
||||||
|
Учебный образец формы, не продуктовая политика:
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: Отбор операций за календарный период
|
||||||
|
При формировании отчёта система SHALL включать все и только операции выбранного
|
||||||
|
счёта, дата учёта которых входит в выбранный период включительно по Europe/Moscow.
|
||||||
|
|
||||||
|
#### Scenario: Последний день периода и следующий день
|
||||||
|
- **GIVEN** выбран период 01.10.2026–31.10.2026 и счёт A
|
||||||
|
- **AND** на счёте A есть операции X с датой учёта 31.10.2026 и Y с датой 01.11.2026
|
||||||
|
- **WHEN** формируется отчёт
|
||||||
|
- **THEN** отчёт включает X и не включает Y
|
||||||
|
|
||||||
|
Плохо: «корректно отбирать операции до конца месяца» — не определены месяц,
|
||||||
|
границы, дата отбора и счёт; «THEN список корректен» — нет проверяемого исхода.
|
||||||
|
Для таблиц решений и переходов используй эталоны из профиля.
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# System Analysis
|
# System Analysis
|
||||||
|
|
||||||
<!-- guide: Hub document. Owns context, boundaries, flows overview and contract
|
<!-- Hub. Владеет контекстом, границами, обзором flows и семантикой контрактов;
|
||||||
semantics; details live in selected modules and are referenced, not copied.
|
детали — в выбранных модулях, на них ссылаются, а не копируют. -->
|
||||||
Remove guides before G3. -->
|
|
||||||
|
|
||||||
## Requirement Coverage
|
## Requirement Coverage
|
||||||
|
|
||||||
| Requirement | Flow / contract | Module |
|
| Requirement | Flow / контракт | Модуль |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
||||||
## System Context and Boundaries
|
## System Context and Boundaries
|
||||||
@@ -19,32 +18,32 @@
|
|||||||
|
|
||||||
## Data Ownership
|
## Data Ownership
|
||||||
|
|
||||||
| Data | Source of truth | Writers | Readers |
|
| Данные | Источник истины | Пишут | Читают |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
||||||
## Interaction Flows
|
## Interaction Flows
|
||||||
|
|
||||||
<!-- guide: Reference diagrams/*.mmd; text only for what a diagram cannot carry. -->
|
<!-- Ссылки на diagrams/*.mmd; текст — только для того, что диаграмма не передаёт. -->
|
||||||
|
|
||||||
## Contracts
|
## Contracts
|
||||||
|
|
||||||
<!-- guide: Per contract: producer/consumer, operation/event, version, file in
|
<!-- На каждый контракт: producer/consumer, операция/событие, версия, файл в
|
||||||
contracts/, compatibility class (additive / breaking), published source it
|
contracts/, класс совместимости (additive / breaking), опубликованный источник,
|
||||||
must match after implementation. -->
|
с которым он обязан совпасть после реализации. -->
|
||||||
|
|
||||||
| Contract | Producer → Consumer | File | Compatibility | Published source |
|
| Контракт | Producer → Consumer | Файл | Совместимость | Опубликованный источник |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Error Semantics
|
## Error Semantics
|
||||||
|
|
||||||
<!-- guide: Business vs technical failures at boundaries. Detailed retry/
|
<!-- Бизнес- и технические отказы на границах. Подробные retry/идемпотентность/
|
||||||
idempotency/recovery belongs to failure-model.md when selected. -->
|
восстановление — в failure-model.md, если выбран. -->
|
||||||
|
|
||||||
## Compatibility / Migration
|
## Compatibility / Migration
|
||||||
|
|
||||||
## Detailed Modules
|
## Detailed Modules
|
||||||
|
|
||||||
<!-- guide: Link each selected module (data-mapping, failure-model, nfr,
|
<!-- Ссылка на каждый выбранный модуль (data-mapping, failure-model, nfr,
|
||||||
observability) with one line on what it decides. -->
|
observability) и одна строка о том, что он решает. -->
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
<!-- guide: Task line format:
|
<!-- Формат task:
|
||||||
- [ ] X.Y [main|frontend|backend|docs|specs] <Kind>: <action + outcome>
|
- [ ] X.Y [main|frontend|backend|docs|specs] <Kind>: <действие + результат>
|
||||||
- **Covers:** `capability` — `Requirement heading` pairs, or `N/A` — <reason>
|
- **Covers:** пары `capability` — `Requirement heading` или `N/A` — <причина>
|
||||||
- **Source:** exact upstream artifact section / contract / docs-impact entry
|
- **Source:** точный раздел upstream-артефакта / контракт / запись docs-impact
|
||||||
- **Verification:** owner-controlled command, observable check or artifact
|
- **Verification:** команда владельца, наблюдаемая проверка или артефакт
|
||||||
- **Depends on:** task ids or none
|
- **Depends on:** ids tasks или none
|
||||||
Kinds: Implementation, Migration, Automated Testing, Contract Parity, Docs,
|
Kinds: Implementation, Migration, Automated Testing, Contract Parity, Docs,
|
||||||
Independent Review, QA Verification, Verification. Remove guides before G3. -->
|
Independent Review, QA Verification, Verification.
|
||||||
|
Пример:
|
||||||
|
- [ ] 2.1 [backend] Implementation: пропускать операции, чей внешний id уже сохранён
|
||||||
|
- **Covers:** `portfolio/holdings` — `Итог портфеля не учитывает дубли операций`
|
||||||
|
- **Source:** design.md Decisions D2; analysis/failure-model.md F3
|
||||||
|
- **Verification:** `npm run verify` в backend; повторный импорт fixture оставляет итог 100.00
|
||||||
|
- **Depends on:** 1.1
|
||||||
|
Плохо: «Verification: проверить, что работает»; Covers с heading, которого нет в specs. -->
|
||||||
|
|
||||||
## 1. <!-- guide: delivery phase -->
|
## 1. <!-- этап delivery -->
|
||||||
|
|
||||||
## Docs Impact
|
## Docs Impact
|
||||||
|
|
||||||
<!-- guide: Only when docs-impact is not selected: list docs/ targets with
|
<!-- Только если docs-impact не выбран: targets в docs/ с итоговым AS-IS или одна
|
||||||
resulting AS-IS, or one concrete no-change reason. Delete when docs-impact.md
|
конкретная причина без изменений. Удали раздел, если есть analysis/docs-impact.md. -->
|
||||||
exists. -->
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Test Cases — <!-- guide: area -->
|
# Test Cases — <!-- область -->
|
||||||
|
|
||||||
<!-- guide: One file per area under test-cases/. A case adds concrete data, steps
|
<!-- Один файл на область в analysis/test-cases/. Case добавляет конкретные данные,
|
||||||
or an oracle that the scenario does not already state; otherwise reference the
|
шаги или oracle, которых нет в Scenario; иначе qa-analysis ссылается на Scenario,
|
||||||
scenario from qa-analysis instead of writing a case. Remove guides. -->
|
а case не пишется. -->
|
||||||
|
|
||||||
### TC-<!-- guide: AREA-NNN --> <!-- guide: title -->
|
### TC-<!-- AREA-NNN --> <!-- название -->
|
||||||
|
|
||||||
- **Verifies:** `capability` — `Requirement heading` / Scenario
|
- **Verifies:** `capability` — `Requirement heading` / Scenario
|
||||||
- **Risk / Priority:**
|
- **Risk / Priority:**
|
||||||
@@ -13,4 +13,4 @@
|
|||||||
- **Steps:**
|
- **Steps:**
|
||||||
1.
|
1.
|
||||||
- **Expected Results:**
|
- **Expected Results:**
|
||||||
- **Automation Candidate:** <!-- guide: yes (level) | no (reason) -->
|
- **Automation Candidate:** <!-- yes (уровень) | no (причина) -->
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
# UX Design
|
# UX Design
|
||||||
|
|
||||||
<!-- guide: Interaction design of observable behavior from Delta Specs. Reference
|
<!-- Interaction design observable behavior из Delta Specs. Ссылайся на требования
|
||||||
requirements as `capability` — `Requirement heading`; do not restate them.
|
как `capability` — `Requirement heading`, не пересказывай. Без серверной
|
||||||
No server architecture, contracts or component/state-management choices.
|
архитектуры, контрактов и выбора компонентов/state management. -->
|
||||||
Remove guides before G3. -->
|
|
||||||
|
|
||||||
## User Goals
|
## User Goals
|
||||||
|
|
||||||
## User Flows
|
## User Flows
|
||||||
|
|
||||||
<!-- guide: Mermaid flowchart or numbered steps per flow, with entry and exit points. -->
|
<!-- Mermaid flowchart или нумерованные шаги на каждый flow, с точками входа и выхода. -->
|
||||||
|
|
||||||
## Information Architecture and Screen Structure
|
## Information Architecture and Screen Structure
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@
|
|||||||
|
|
||||||
## UI States
|
## UI States
|
||||||
|
|
||||||
| Screen / region | Loading | Empty | Error | Success | Partial / stale |
|
| Экран / область | Loading | Empty | Error | Success | Partial / stale |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
## Validation Feedback
|
## Validation Feedback
|
||||||
@@ -26,15 +25,15 @@
|
|||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
<!-- guide: Keyboard path, focus order, names/roles, contrast, non-color cues. -->
|
<!-- Путь с клавиатуры, порядок фокуса, имена/роли, контраст, признаки не только цветом. -->
|
||||||
|
|
||||||
## Design System Reuse
|
## Design System Reuse
|
||||||
|
|
||||||
## Materials
|
## Materials
|
||||||
|
|
||||||
<!-- guide: Inline Markdown by default; local files follow the Visual Package rule. -->
|
<!-- По умолчанию inline Markdown; локальные файлы — по правилу Visual Package. -->
|
||||||
|
|
||||||
| Material ID | Relative path / reference | Screen/state and purpose | SHA-256 |
|
| ID материала | Относительный путь / ссылка | Экран/состояние и назначение | SHA-256 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
||||||
## Open UX Questions
|
## Open UX Questions
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-specs",
|
"name": "wealth-ledger-specs",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-specs",
|
"name": "wealth-ledger-specs",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "21.2.4",
|
"@commitlint/cli": "21.2.4",
|
||||||
"@commitlint/config-conventional": "21.2.4",
|
"@commitlint/config-conventional": "21.2.4",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-specs",
|
"name": "wealth-ledger-specs",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.22.1"
|
"node": "24.22.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user