73 Commits
Author SHA1 Message Date
ksv741 97f358545f chore(release): 0.2.0
Conventional commits / commits (pull_request) Successful in 9s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 8s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 8s
2026-11-01 19:34:04 +03:00
codex-bot c66a5cd962 spec: record account-top-positions planning and implementation (WealthLedger/main#48) (#69)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-11-01 19:29:11 +03:00
codex-bot b9ec11f5ba refactor: make sdd-lab review-driven (WealthLedger/specs#66)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
Replace executable schema checks with explicit review-driven readiness, add a requirement-writing profile, and incorporate review clarifications for source traceability and comparative validation.
2026-10-30 13:35:39 +03:00
codex-bot a2d2df3b5a spec: archive group-account-rows-by-instrument and sync canonical specs (WealthLedger/main#47) (#65)
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 7s
2026-10-30 07:35:30 +03:00
codex-bot f3a6b78461 spec: plan grouping of all-account rows by instrument (WealthLedger/main#47) (#64)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-10-30 07:28:31 +03:00
codex-bot 322ddf6a7f feat: add experimental wealth-ledger-sdd-lab OpenSpec schema (#61)
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 7s
2026-10-29 05:58:29 +03:00
ksv741 093185d44d chore(release): 0.1.6
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 10s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-10-24 19:15:05 +03:00
renovate-bot 08646ff505 chore(deps): update non-major dependencies (WealthLedger/specs#56)
Specs security / security (push) Successful in 16s
Specs verify / verify (push) Successful in 11s
Co-authored-by: renovate-bot <renovate-bot@ksv741.ru>
2026-10-24 19:04:54 +03:00
codex-bot c5b9cadbbd chore: bump Node to 24.22.1 (WealthLedger/backend#122)
Specs security / security (push) Successful in 10s
Specs verify / verify (push) Successful in 13s
2026-10-23 16:20:01 +03:00
ksv741 ab24ccc32b chore(release): 0.1.5
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 10s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
2026-10-18 16:24:55 +03:00
codex-bot f0478c6d6b spec: archive quiet-user-feedback and sync canonical specs (#50)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
Native archive применил 9 added и 12 modified требований без удалений; все 10 сценариев сохранены. Новый canonical spec frontend-user-feedback внесён в AI_INDEX.
2026-10-17 22:18:27 +03:00
codex-bot 5a7e319caf spec: reconcile quiet-user-feedback with source-state requirements (#50)
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 10s
Переносит change quiet-user-feedback в develop и согласует его delta-блоки с текущими canonical specs. Нормативное поведение: тихое чтение клиента и видимое состояние источника из meta.
2026-10-17 21:46:58 +03:00
codex-bot 06d08e2972 chore: adopt linear git model in specs instructions (#48)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-10-17 14:14:17 +03:00
codex-bot 1bd6181585 spec: align user-authentication password requirement with ASCII 8-72 policy (WealthLedger/specs#46) (#47)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
2026-10-16 10:47:41 +03:00
codex-bot 0a1db52baf Merge pull request 'chore: synchronize release 0.1.4 into develop (WealthLedger/main#30)' (#45) from chore/30-sync-release into develop
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
2026-10-16 07:48:20 +03:00
codex-bot 5ca44d16b1 Merge pull request 'chore: bring develop into release sync branch (WealthLedger/main#30)' (#44) from develop into chore/30-sync-release
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-16 07:41:05 +03:00
codex-bot 7da2f90e0a chore: make OpenSpec toolchain explicit and diagnosable (WealthLedger/specs#4) (#43)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-15 06:34:41 +03:00
codex-bot 7ebfc510af spec: clarify SDD applies only on explicit request (WealthLedger/specs#1)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
2026-10-14 22:43:01 +03:00
codex-bot d5d5480eed spec: add five capability entries to AI_INDEX (WealthLedger/specs#28)
Specs security / security (push) Canceled after 0s
Specs verify / verify (push) Canceled after 0s
2026-10-14 22:43:00 +03:00
codex-bot 4b2806ead5 Merge pull request 'spec: archive three completed changes (main#33, frontend#63, screener)' (#40) from specs/archive-three-changes into develop
Specs security / security (push) Successful in 17s
Specs verify / verify (push) Successful in 10s
2026-10-14 21:46:55 +03:00
ksv741 6efb989ed9 spec: archive three completed changes (main#33, frontend#63, screener)
Conventional commits / commits (pull_request) Successful in 13s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 12s
2026-10-14 21:40:26 +03:00
codex-bot 0ed462d157 Merge pull request 'spec: fix upcoming events date filter default and picker bounds (WealthLedger/frontend#63)' (#39) from specs/63-upcoming-events-date-filter into develop
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 12s
2026-10-14 21:35:02 +03:00
ksv741 b4c0589745 spec: fix upcoming events date filter default and picker bounds (WealthLedger/frontend#63)
Conventional commits / commits (pull_request) Successful in 14s
Specs security / security (pull_request) Successful in 9s
Specs verify / verify (pull_request) Successful in 11s
2026-10-14 21:24:13 +03:00
codex-bot 6c33c770db Merge pull request 'ci: rerun commits proof on PR title edit (WealthLedger/specs#30)' (#38) from fix/30-commits-workflow-edited into develop
Specs security / security (push) Successful in 34s
Specs verify / verify (push) Successful in 11s
2026-10-13 20:33:44 +03:00
ksv741 4e3855fc35 ci: rerun commits proof on PR title edit (WealthLedger/specs#30)
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 14s
2026-10-13 20:18:57 +03:00
codex-bot 6c858a8858 spec: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#37)
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 12s
spec: unify income payment cards and drop period comparisons (WealthLedger/main#33)
2026-10-13 18:16:27 +03:00
ksv741 aae23d86f0 ci: validate release PR commits independently (WealthLedger/main#30)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 23:39:30 +03:00
ksv741 ebc9ca872c chore(release): 0.1.4
Conventional commits / commits (pull_request) Failing after 11s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 23:19:12 +03:00
ksv741 fe295aa73a spec: archive harden-hourly-sync-lifecycle change (WealthLedger/backend#56)
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
2026-10-12 22:52:44 +03:00
ksv741 e8e68a7ed6 Merge pull request 'spec: add harden-hourly-sync-lifecycle change (WealthLedger/backend#56)' (#33) from fix/56-harden-hourly-sync-lifecycle into develop
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
Reviewed-on: #33
2026-10-12 22:29:16 +03:00
ksv741 77b211186c spec: address hourly-sync review findings (WealthLedger/backend#56)
Conventional commits / commits (pull_request) Successful in 14s
Specs security / security (pull_request) Successful in 9s
Specs verify / verify (pull_request) Successful in 11s
2026-10-12 22:19:03 +03:00
ksv741 14174a7bb3 spec: add harden-hourly-sync-lifecycle change (WealthLedger/backend#56)
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 20:28:58 +03:00
codex-bot 78f01066d2 Merge pull request 'chore: align specs engineering workflow (WealthLedger/main#27)' (#32) from chore/27-engineering-workflow into develop
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-10-12 11:00:37 +03:00
ksv741 7f12327c37 docs: pin specs workflow reference (WealthLedger/main#27)
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 10:22:13 +03:00
ksv741 a29cbf6aae fix: align specs commitlint workflow (WealthLedger/main#27)
Conventional commits / commits (pull_request) Canceled after 0s
Specs security / security (pull_request) Canceled after 0s
Specs verify / verify (pull_request) Canceled after 0s
2026-10-12 10:21:32 +03:00
ksv741 a441b08378 chore: align specs engineering workflow (WealthLedger/main#27)
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-12 09:21:12 +03:00
codex-bot ea7beea863 Merge pull request 'docs: clarify specs Node bootstrap' (#31) from codex/issue-24-process into develop
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
2026-10-11 23:23:10 +03:00
ksv741 18518e0d2d docs: clarify specs Node bootstrap
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 11s
2026-10-11 23:06:51 +03:00
codex-bot 0b23fc3013 spec: add stock screener
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
2026-10-11 11:45:09 +03:00
ksv741 fcdc805f6c ci: skip checks on PR metadata edits
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 8s
2026-10-11 11:36:51 +03:00
ksv741 c132770fbb ci: avoid duplicate PR checks
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 8s
Conventional commits / commits (pull_request) Successful in 9s
2026-10-11 11:24:57 +03:00
ksv741 951f1a9a20 spec: add stock screener
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-11 11:18:21 +03:00
ksv741 329d66e767 spec: finalize wealth-ledger SDD migration
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
2026-10-09 21:14:22 +03:00
codex-bot 90edab6e33 Merge pull request 'spec: define hourly portfolio sync history' (#25) from release/0.1.3 into develop
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Merge hourly portfolio sync history specs
2026-10-09 09:19:38 +03:00
ksv741 b24a686188 spec: define hourly portfolio sync history
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-09 09:05:04 +03:00
ksv741 1ee31e3b49 chore(release): 0.1.3
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
2026-10-07 18:13:58 +03:00
ksv741 a1b0e99bdd spec: add reduce-screen-sync-polling change
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
OpenSpec change: screen sync status polling and observe reads run
every five seconds during active sync; terminal state observes
resources immediately.
2026-10-07 14:25:16 +03:00
ksv741 0fd148ffbb chore: run commitlint in commit-msg hook
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 11s
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
Local commits now get the same commitlint --strict check as CI.
2026-10-07 14:18:14 +03:00
ksv741 33cc4f3e0c fix(deps): patch release tooling advisories
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 10s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 10s
Scope patched basic-ftp and undici overrides to release-it.

Keep the existing development-dependency security gate.
2026-10-03 19:47:30 +03:00
ksv741 d861de5ead spec: simplify runtime requirements
Specs security / security (push) Failing after 8s
Specs verify / verify (push) Successful in 10s
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Failing after 8s
Specs verify / verify (pull_request) Successful in 10s
Preserve identity isolation and financial integrity while accepting
cross-tab reauthentication and on-demand candle history.

Align portfolio freshness with persisted reads and keep income
consistency at the backend owner.
2026-10-03 11:38:08 +03:00
ksv741 4d580f145a chore(release): 0.1.2
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 10s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-09-26 22:10:11 +03:00
ksv741 2f5814abdd spec: complete persist-tbank-data implementation
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 11s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Record the approved proposal, delta specifications, design and 27 verified
implementation tasks. Keep the change active; archiving and main-spec synchronization
are deferred.
2026-09-26 21:29:24 +03:00
ksv741 ba76f6fccc spec: complete local frontend stack evolution
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 11s
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
OpenSpec change: evolve-frontend-stack
2026-09-22 22:11:05 +03:00
ksv741 7f40372c3f spec: define versioned deployment bundle
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 11s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-09-20 18:49:25 +03:00
ksv741 e7a3c30b5d chore(release): 0.1.1
Conventional commits / commits (pull_request) Successful in 13s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-09-16 11:35:57 +03:00
ksv741 b34f23a230 spec: define containerized application delivery #10
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 8s
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
2026-09-16 09:41:59 +03:00
ksv741 e4f10dedef docs: make specs instructions self-contained
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-09-15 20:45:41 +03:00
ksv741 9e227a563d chore(release): 0.1.0
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
Include the reviewed historical OpenAPI fixture fingerprint in the release candidate. Keep full-history scanning enabled.
2026-09-14 07:09:53 +03:00
ksv741 246c754257 chore(release): 0.1.0
Specs security / security (push) Failing after 5s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Failing after 6s
Specs verify / verify (pull_request) Successful in 11s
2026-09-14 07:04:04 +03:00
ksv741 3c7c646f13 chore: configure releases and develop workflow 2026-09-14 07:01:15 +03:00
ksv741 50658ea8be docs: update system documentation references 2026-09-14 05:52:11 +03:00
ksv741 05e314cebb docs(specs): remove runtime OpenAPI gate 2026-09-13 19:08:13 +03:00
ksv741 9590dedc01 docs: align runtime requirements with Node 24 LTS 2026-09-13 15:05:40 +03:00
ksv741 834f2d146c docs: record completed manual bank card change
Track the approved proposal, delta specification and design with all 29 verified implementation tasks. Keep the change active without archiving or syncing main specifications.
2026-09-12 18:34:20 +03:00
ksv741 43a5577e72 docs: link to unified documentation entry 2026-09-12 14:45:48 +03:00
ksv741 bcb10da21c docs(ai): archive superseded context routing plan 2026-09-09 16:23:19 +03:00
ksv741 fbb7193e90 spec: archive income page simplification with evidence 2026-09-09 13:54:09 +03:00
ksv741 4c905c1433 spec: record approved income page simplification 2026-09-09 13:50:45 +03:00
ksv741 0608d4dc23 spec: remove income analytics period limit 2026-09-09 11:10:11 +03:00
ksv741 d5c18258c4 spec: archive compact portfolio totals 2026-09-09 10:32:27 +03:00
ksv741 b06a35bbaa spec: deliver compact portfolio totals package 2026-09-09 10:31:09 +03:00
ksv741 19a658e318 spec: archive local design token simplification 2026-09-08 20:20:51 +03:00
ksv741 f171b8bb46 spec: simplify local design token validation contract 2026-09-08 20:18:40 +03:00
276 changed files with 32714 additions and 372 deletions
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+34
View File
@@ -0,0 +1,34 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
concurrency:
group: specs-commits-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
+35
View File
@@ -0,0 +1,35 @@
name: Specs security
on:
push:
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '17 5 * * *'
concurrency:
group: specs-security-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+22
View File
@@ -0,0 +1,22 @@
name: Specs verify
on:
push:
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: specs-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci
- run: npm run verify
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
npx --no-install commitlint --edit "$1" --strict
+1
View File
@@ -0,0 +1 @@
node_modules/
+4
View File
@@ -0,0 +1,4 @@
# Reviewed historical false positive: the removed runtime OpenAPI check used
# base64 of 0123456789abcdef repeated twice for its disposable SQLite database.
# This fingerprint covers only that historical line; new commits remain scanned.
72444dbbb11ae267e349adbf35256bd7c5be1489:scripts/verify-runtime-openapi.sh:generic-api-key:57
+1
View File
@@ -0,0 +1 @@
24.22.1
+106
View File
@@ -0,0 +1,106 @@
{
"git": {
"requireBranch": "release/*",
"requireCleanWorkingDir": true,
"requireUpstream": false,
"requireCommits": true,
"commitMessage": "chore(release): ${version}",
"tagName": "v${version}",
"tag": false,
"push": false
},
"npm": {
"publish": false
},
"github": {
"release": false
},
"gitlab": {
"release": false
},
"hooks": {
"after:git:init": "node scripts/check-release.cjs"
},
"plugins": {
"@release-it/conventional-changelog": {
"infile": "CHANGELOG.md",
"preset": {
"name": "conventionalcommits",
"types": [
{
"type": "chore",
"scope": "release",
"effect": "hidden"
},
{
"type": "feat",
"section": "Features",
"effect": "bump"
},
{
"type": "fix",
"section": "Bug Fixes",
"effect": "bump"
},
{
"type": "perf",
"section": "Performance",
"effect": "bump"
},
{
"type": "docs",
"section": "Documentation",
"effect": "bump"
},
{
"type": "spec",
"section": "Specifications",
"effect": "bump"
},
{
"type": "refactor",
"section": "Refactoring",
"effect": "bump"
},
{
"type": "build",
"section": "Build",
"effect": "bump"
},
{
"type": "ci",
"section": "CI",
"effect": "bump"
},
{
"type": "test",
"section": "Tests",
"effect": "bump"
},
{
"type": "style",
"section": "Styles",
"effect": "bump"
},
{
"type": "chore",
"section": "Maintenance",
"effect": "bump"
},
{
"type": "revert",
"section": "Reverts",
"effect": "bump"
}
]
},
"context": {
"host": "https://git.personal.ksv741.ru",
"owner": "WealthLedger",
"repository": "specs",
"linkReferences": true,
"commit": "commit"
}
}
}
}
+31 -37
View File
@@ -1,43 +1,37 @@
# Specs # Specs
## Назначение и authority Репозиторий содержит существующие behavioral specifications и SDD history.
Для обычной разработки не создавай и не обновляй planning artifacts ради
процесса. До edits найди/создай подходящую Gitea Issue, проверь AC и ветку;
чтение допускается без Issue. Выбор lightweight/SDD — по
[Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`);
OpenSpec lifecycle начинается только после явного решения Change Owner.
Этот репозиторий — единственный versioned authority для change artifacts. Если нужна существующая behavioral spec, выбери точный target через
Workspace layout и reference policy — в root `AGENTS.md`. [AI_INDEX](AI_INDEX.md). Specs не являются вторым editable HTTP OpenAPI:
HTTP-провайдер публикует контракт, а потребитель получает отдельный artifact
и локально проверяет generated types. Specs фиксируют behavioral requirements;
другие checkout не нужны для чтения и native validation этого store.
Canonical OpenSpec store — `wealth-ledger-specs`, физический root от workspace — Архивы и чужие active changes сохраняй. Для правки индекса проверь targets,
`specs/openspec` (локально `openspec`). OpenSpec commands, читающие или anchors и `git diff --check`; application gates принадлежат affected repos.
изменяющие change, всегда получают `--store wealth-ledger-specs`. Если явно запрошена работа через OpenSpec, следуй
[локальным инструкциям OpenSpec](README.md#начало-работы)
и используй store `wealth-ledger-specs`. Перед Node-командами активируй
местную `.nvmrc`: если `nvm` не загружен в non-interactive shell, загрузи
установленный `$HOME/.nvm/nvm.sh`, затем выполни `nvm use --silent` из specs.
Если `nvm.sh` недоступен, остановись; не используй системный Node.
Proposal, Delta Specs, Design, Tasks, changes, Completion Evidence и archives ## Ветки и релизы
хранятся в `openspec`; `docs` владеет as-built документацией и общей policy.
## Workflow и verification Работа — в issue-linked ветке от `develop`, затем PR в `develop`; в `main` напрямую
не пушить. PR сливается squash или rebase-merge; merge-коммит не используется.
Новые changes используют project-local schema `wealth-ledger-sdd`. `main` обновляется только fast-forward из `develop`, поэтому `origin/main`
Для planning, implementation, validation и завершения читай canonical lifecycle: всегда предок `origin/develop`:
[`../docs/development/openspec-authority-and-lifecycle.md`](../docs/development/openspec-authority-and-lifecycle.md). `git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Сначала определить current stage и capability paths через native OpenSpec Релиз — по [README](README.md#релизы).
instruction; читать main specs и supporting docs только для этих paths и Commits и заголовки PR — Conventional Commits со ссылкой на Issue (кроме release);
affected areas. Recovery и выбор relevant evidence определяет context discipline для спецификаций допустим `spec:`.
из [`../docs/development/agent-token-efficiency.md`](../docs/development/agent-token-efficiency.md), Перед подготовкой релиза прочитай местный README; версии вручную не повышать.
а не дублируется в schema instructions.
Для structural validation из `specs`:
```bash
openspec schema validate wealth-ledger-sdd --verbose
openspec validate <change> --type change --strict --store wealth-ledger-specs
openspec validate --specs --strict --store wealth-ledger-specs
```
Под Node 22: если локальной `.nvmrc` нет, активировать frontend `.nvmrc`
через `nvm use --silent` до перехода в specs. Native structural gate проверяет
форму; terminal safeguards определяет canonical lifecycle, доступный без skills.
## Boundaries
- Design содержит Delivery Map `frontend`, `backend`, `specs`, `docs` и условный Solution Assessment.
- Specs описывают observable behavior, не изменения кода; Design — architecture, не line-by-line implementation; Tasks — implementation checklist без будущего evidence.
- Application code и durable ADR принадлежат affected application/docs repos.
+41
View File
@@ -0,0 +1,41 @@
# Behavioral specifications index
Эти существующие specs — источник сохранённых требований по конкретной теме.
Читай нужный requirement/scenario, если задача зависит от него; не загружай всё
дерево и не создавай OpenSpec change для обычной разработки.
Этот индекс и основные specs читаются без соседних checkout. Внешние
сведения о реализации служат справочными свидетельствами, когда предоставлены.
При конфликте с текущим поведением установи расхождение; не копируй устаревшее
правило или случайный код молча.
HTTP OpenAPI имеет единственный editable source в backend, а не здесь.
Потребитель доверяет предоставленному artifact и локально проверяет generated
types. CI каждого участника использует только его собственные входы.
| Тема / trigger | Existing spec |
| --- | --- |
| Auth/session/refresh/error contract | [User authentication](openspec/specs/user-authentication/spec.md) |
| T-Bank connection/security/cache generation | [T-Bank connection](openspec/specs/tbank-connection/spec.md) |
| Владелец синхронизации T-Bank, scheduling/priority | [T-Bank data sync](openspec/specs/tbank-data-sync/spec.md) |
| Часовой обход, результат последней попытки | [Hourly investment sync](openspec/specs/hourly-investment-sync/spec.md) |
| Счета/сводки | [Brokerage accounts](openspec/specs/brokerage-accounts/spec.md) |
| Portfolio positions/totals/stale | [Portfolio](openspec/specs/portfolio-view/spec.md) |
| История стоимости Portfolio | [Portfolio value history](openspec/specs/portfolio-value-history/spec.md) |
| Справочные сведения/поиск инструментов (online-only) | [Online instrument information](openspec/specs/online-instrument-information/spec.md) |
| Ручные дебетовые карты | [Manual bank cards](openspec/specs/manual-bank-cards/spec.md) |
| Операции/cursor/filters | [Account ledger](openspec/specs/brokerage-account-ledger/spec.md) |
| Будущие выплаты | [Upcoming events](openspec/specs/brokerage-account-upcoming-events/spec.md) |
| Доходы и периоды | [Income analytics](openspec/specs/brokerage-account-income-analytics/spec.md) |
| Shared tokens/theme | [Design system](openspec/specs/frontend-design-system/spec.md) |
| Каталог компонентов | [UI catalog](openspec/specs/frontend-ui-catalog/spec.md) |
| Loading/skeleton | [Loading presentation](openspec/specs/frontend-loading-presentation/spec.md) |
| UI error/recovery | [UI resilience](openspec/specs/frontend-ui-resilience/spec.md) |
| Тихое чтение и редкие уведомления | [User feedback](openspec/specs/frontend-user-feedback/spec.md) |
| Query coherence/cache | [Query coherence](openspec/specs/frontend-query-coherence/spec.md), [data resilience](openspec/specs/frontend-data-resilience/spec.md) |
| OpenAPI generation | [API contract generation](openspec/specs/api-contract-generation/spec.md) |
| Logging/request context | [Structured logging](openspec/specs/backend-structured-logging/spec.md) |
| Breadcrumb/navigation | [Breadcrumbs](openspec/specs/app-breadcrumb-navigation/spec.md) |
Process specs, active changes и archives сохраняют историю прежнего SDD workflow.
Они не являются обязательной процедурой текущей задачи. Для явно запрошенной
работы с OpenSpec используй [отдельный вход](AGENTS.md).
+207
View File
@@ -0,0 +1,207 @@
# 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)
### Maintenance
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/specs/issues/122)) ([c5b9cad](https://git.personal.ksv741.ru/WealthLedger/specs/commit/c5b9cadbbd2dfbb6f139bc71851117d81c0e7c6d))
* **deps:** update non-major dependencies (WealthLedger/specs[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([08646ff](https://git.personal.ksv741.ru/WealthLedger/specs/commit/08646ff505eed501e54314c07ca4d1076a4c37dd))
## [0.1.5](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.4...v0.1.5) (2026-10-18)
### Specifications
* add five capability entries to AI_INDEX (WealthLedger/specs[#28](https://git.personal.ksv741.ru/WealthLedger/specs/issues/28)) ([d5d5480](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d5d5480eedbc25c77c52b3b477de37e373beb8d9))
* align user-authentication password requirement with ASCII 8-72 policy (WealthLedger/specs[#46](https://git.personal.ksv741.ru/WealthLedger/specs/issues/46)) ([#47](https://git.personal.ksv741.ru/WealthLedger/specs/issues/47)) ([1bd6181](https://git.personal.ksv741.ru/WealthLedger/specs/commit/1bd618158588871f1801bfc6bead82fba31ce0ae))
* archive quiet-user-feedback and sync canonical specs ([#50](https://git.personal.ksv741.ru/WealthLedger/specs/issues/50)) ([f0478c6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/f0478c6d6b5a5f259ea766276295758b34d240b3))
* archive three completed changes (main[#33](https://git.personal.ksv741.ru/WealthLedger/specs/issues/33), frontend[#63](https://git.personal.ksv741.ru/WealthLedger/specs/issues/63), screener) ([6efb989](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6efb989ed9051981a314c8eb5714dffa88195a80))
* clarify SDD applies only on explicit request (WealthLedger/specs[#1](https://git.personal.ksv741.ru/WealthLedger/specs/issues/1)) ([7ebfc51](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7ebfc510afb7440f85ecf95e2891d84c1b8ad843))
* fix upcoming events date filter default and picker bounds (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/specs/issues/63)) ([b4c0589](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b4c0589745e0d1fb6b482addbea82d06c4924dad))
* reconcile quiet-user-feedback with source-state requirements ([#50](https://git.personal.ksv741.ru/WealthLedger/specs/issues/50)) ([5a7e319](https://git.personal.ksv741.ru/WealthLedger/specs/commit/5a7e319cafd8d9b6861b5a6bab7085bb57b08576))
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/specs/issues/33)) ([#37](https://git.personal.ksv741.ru/WealthLedger/specs/issues/37)) ([6c858a8](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6c858a8858dfdfc5c9607b3f925de5900ef1c903))
### CI
* rerun commits proof on PR title edit (WealthLedger/specs[#30](https://git.personal.ksv741.ru/WealthLedger/specs/issues/30)) ([4e3855f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/4e3855fc3526e2b0b7ba80fd23b996d9993edcf5))
### Maintenance
* adopt linear git model in specs instructions ([#48](https://git.personal.ksv741.ru/WealthLedger/specs/issues/48)) ([06d08e2](https://git.personal.ksv741.ru/WealthLedger/specs/commit/06d08e29724e6cbe95eeca5ee1e6af7ec365a559))
* make OpenSpec toolchain explicit and diagnosable (WealthLedger/specs[#4](https://git.personal.ksv741.ru/WealthLedger/specs/issues/4)) ([#43](https://git.personal.ksv741.ru/WealthLedger/specs/issues/43)) ([7da2f90](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7da2f90e0a6a7e0e5cb40b371c965a772aaa5db2))
## [0.1.4](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.3...v0.1.4) (2026-10-12)
### Bug Fixes
* align specs commitlint workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/specs/issues/27)) ([a29cbf6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a29cbf6aae7ebe48a51ae1979ff3f30b9fa8e305))
### Documentation
* clarify specs Node bootstrap ([18518e0](https://git.personal.ksv741.ru/WealthLedger/specs/commit/18518e0d2daecb843fea1b4d55d1c7d2cabf24f6))
* pin specs workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/specs/issues/27)) ([7f12327](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7f12327c37055ba903f962b1220138da9c7edd06))
### Specifications
* add harden-hourly-sync-lifecycle change (WealthLedger/backend[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([14174a7](https://git.personal.ksv741.ru/WealthLedger/specs/commit/14174a7bb335152c8cc76d9a6bbaefaaaa58b22f))
* add stock screener ([951f1a9](https://git.personal.ksv741.ru/WealthLedger/specs/commit/951f1a9a20a09695e2ff85d1ee96632e98a202d8))
* address hourly-sync review findings (WealthLedger/backend[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([77b2111](https://git.personal.ksv741.ru/WealthLedger/specs/commit/77b211186c297df4187e044804786ffc9096ce19))
* archive harden-hourly-sync-lifecycle change (WealthLedger/backend[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([fe295aa](https://git.personal.ksv741.ru/WealthLedger/specs/commit/fe295aa73ab5bc9d826067892930b7161fb4f1b9))
* define hourly portfolio sync history ([b24a686](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b24a6861887e831ef2d73a97f36cc213be2be0b8))
* finalize wealth-ledger SDD migration ([329d66e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/329d66e767f7e89a40b96bb8e5b499f39f67feb6))
### CI
* avoid duplicate PR checks ([c132770](https://git.personal.ksv741.ru/WealthLedger/specs/commit/c132770fbb4a4e48cae0a4ceca7407bb6a768ded))
* skip checks on PR metadata edits ([fcdc805](https://git.personal.ksv741.ru/WealthLedger/specs/commit/fcdc805f6c1b3be6a20a7e858e3e80cbd83a9121))
### Maintenance
* align specs engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/specs/issues/27)) ([a441b08](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a441b083785d8f50c42dea543e684b23f27732a2))
## [0.1.3](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.2...v0.1.3) (2026-10-07)
### Bug Fixes
* **deps:** patch release tooling advisories ([33cc4f3](https://git.personal.ksv741.ru/WealthLedger/specs/commit/33cc4f3e0c20ed3d6f5814cdc609724ffb8564ac))
### Specifications
* add reduce-screen-sync-polling change ([a1b0e99](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a1b0e99bddb91f7b7ca729ddf6b7d6c688fdca41))
* simplify runtime requirements ([d861de5](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d861de5ead41b4977083b4aee08fc668c35e5b34))
### Maintenance
* run commitlint in commit-msg hook ([0fd148f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0fd148ffbb39ea883a7e7799aa1b87c93b31fa17))
## [0.1.2](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.1...v0.1.2) (2026-09-26)
### Specifications
* complete local frontend stack evolution ([ba76f6f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/ba76f6fccc63b315e9ebf9d05ce181b283a6c094))
* complete persist-tbank-data implementation ([2f5814a](https://git.personal.ksv741.ru/WealthLedger/specs/commit/2f5814abdd52984bf1bcbd1d09b27ff4df3d7a25))
* define versioned deployment bundle ([7f40372](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7f40372c3f67bb67cc77feaad17b61573c65f41e))
## [0.1.1](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.0...v0.1.1) (2026-09-16)
### Documentation
* make specs instructions self-contained ([e4f10de](https://git.personal.ksv741.ru/WealthLedger/specs/commit/e4f10dedefde06d5c710ea36ab99b199cf2c1f7f))
### Specifications
* define containerized application delivery [#10](https://git.personal.ksv741.ru/WealthLedger/specs/issues/10) ([b34f23a](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b34f23a23034195a6ae0ff324c20182e664bb26d))
## 0.1.0 (2026-09-14)
### Features
* add authorization ([b4515a2](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b4515a201aa63a46754f11c6a9644da216efeaeb))
* add breadcrumbs ([113ad5e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/113ad5e9a2ef4a0f5b7d626fe7d248f5b877bde9))
* add brocker account list ([e907542](https://git.personal.ksv741.ru/WealthLedger/specs/commit/e9075425df4f5871b7a818aeba809aa440284214))
* add future events ([d4851df](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d4851dfdb7c4bab00e61efe7b778ddc377d769c3))
* add logging ([aac1b43](https://git.personal.ksv741.ru/WealthLedger/specs/commit/aac1b435af29efbe0c12428eda1d8f938bcb6ac3))
* add operations list ([e5517cc](https://git.personal.ksv741.ru/WealthLedger/specs/commit/e5517cceae2fc495a36335609b3173cada7a78f3))
* add portfolio ([e3cc3d5](https://git.personal.ksv741.ru/WealthLedger/specs/commit/e3cc3d540bdef00c700b955340dbb5ad2eeec0c3))
* harden OpenSpec agent resilience ([8462a3b](https://git.personal.ksv741.ru/WealthLedger/specs/commit/8462a3ba75d6dda491b9221699357c58d8f8e3cc))
* harden OpenSpec SDD workflow ([a8b2c4e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a8b2c4eed988f7e1a7b3d6b7188bbe6791f00e2a))
* little frontend structure refactor ([cdc5ae8](https://git.personal.ksv741.ru/WealthLedger/specs/commit/cdc5ae89951e2639ef5a635f785068bfa8d9f316))
* **portfolio-view:** sync delta spec and archive change ([678a671](https://git.personal.ksv741.ru/WealthLedger/specs/commit/678a671d23b3f3db1d2ad8989924ffb35a9687ee))
* redesign portfolio position cards ([6795ee6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6795ee6f96911fe9cd0ba12e630009ee707272e4))
### Bug Fixes
* **specs:** remove archived change duplicate ([9f68ae5](https://git.personal.ksv741.ru/WealthLedger/specs/commit/9f68ae52557409dacbae50284d9e3635dd0e031b))
### Documentation
* add baseline OpenSpec change ([2d17778](https://git.personal.ksv741.ru/WealthLedger/specs/commit/2d1777823b411846161f3c2c211e46b2e97ea1a2))
* add MVP change proposals ([e383e5f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/e383e5fac6051722510f812b8787cd8c0317c216))
* add React component style change ([18a9487](https://git.personal.ksv741.ru/WealthLedger/specs/commit/18a94874325169e1e3e6fa4d4669b120c7886475))
* **agents:** link audit remediation registry ([6a38cf1](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6a38cf1e25927805b5fa43b43378efff175441fd))
* **ai:** archive superseded context routing plan ([bcb10da](https://git.personal.ksv741.ru/WealthLedger/specs/commit/bcb10da21cc2e2efb5c6951dfcff89ea65ca7ab4))
* align runtime requirements with Node 24 LTS ([9590ded](https://git.personal.ksv741.ru/WealthLedger/specs/commit/9590dedc0122cc045d24ba01321bbf4715516405))
* archive baseline change ([68be476](https://git.personal.ksv741.ru/WealthLedger/specs/commit/68be4760ce1a360d61b6c8924d3ac4b597001da8))
* archive React component style change ([6898732](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6898732dd8e933e9d6a587aef48ebe0eddaa0cf2))
* archive T-Bank connection change ([7edfcb9](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7edfcb9dba35f75ce76f97e643c2e7492afdd82c))
* complete T-Bank connection change ([cc05b2b](https://git.personal.ksv741.ru/WealthLedger/specs/commit/cc05b2b69cf8f82ff7e76673990fb901168f88a8))
* link to unified documentation entry ([43a5577](https://git.personal.ksv741.ru/WealthLedger/specs/commit/43a5577e72ed6556f7db5d10689c2cc3cbdd60e3))
* record completed manual bank card change ([834f2d1](https://git.personal.ksv741.ru/WealthLedger/specs/commit/834f2d146cb99675be5901b40a4d32039d250210))
* **sdd:** add tbank access refactor change ([79faa29](https://git.personal.ksv741.ru/WealthLedger/specs/commit/79faa29ac07172a16de617cd09003b5e7186900e))
* **sdd:** archive tbank access refactor ([736885f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/736885fe6555c09a2d68b35994692c4fafdac518))
* **specs:** add audit remediation workflow plan ([ea58797](https://git.personal.ksv741.ru/WealthLedger/specs/commit/ea587970ae6123bb5fe5289a138ef42878dbd7ab))
* **specs:** archive account-ledger-explicit-filters ([69d4e63](https://git.personal.ksv741.ru/WealthLedger/specs/commit/69d4e63a5110f744a2428dd9d40525bf025a2db8))
* **specs:** archive add-instrument-logos ([70a200e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/70a200e1b41992c8909f6f2daae4264afc442b9d))
* **specs:** archive api contract resilience remediation ([800450d](https://git.personal.ksv741.ru/WealthLedger/specs/commit/800450d07549475d2c83665b58a69c143db5349c))
* **specs:** archive audit remediation workflow ([d093902](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d0939026fbf5e40a55390688c58a8929f8a23927))
* **specs:** archive auth and cache remediation ([6e20cdf](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6e20cdffaf2f57065f2c99c2d3dd7b66cb1d9903))
* **specs:** archive brokerage account list change ([6f47589](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6f47589211afd2cfcecb77884189203038e82732))
* **specs:** archive consume-mantine-components change ([b5df309](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b5df309728afd1b96f2012b0b5b4d8271ee182a3))
* **specs:** archive dependency hygiene remediation ([8897bd9](https://git.personal.ksv741.ru/WealthLedger/specs/commit/8897bd9585004b89e4347712c33bd91867c50015))
* **specs:** archive documentation entrypoints remediation ([5b7bc0f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/5b7bc0fdaf8202acfc8ac1ef5510b3f3abad2bbc))
* **specs:** archive domain money contract change ([039b72e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/039b72eed38b07e89e2f0234e7c12c202b6cf262))
* **specs:** archive frontend audit remediation ([ab231a6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/ab231a6aac58f4e97377e4d4fb521e22d3f07fdb))
* **specs:** archive frontend auth query gating change ([6e2890f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6e2890f86f5316fdb821b043084461314eb8cf13))
* **specs:** archive frontend quality guardrails ([2029519](https://git.personal.ksv741.ru/WealthLedger/specs/commit/20295190ddb02cb279a6b404d40d944bde862c3d))
* **specs:** archive frontend query remediation ([b8a3f96](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b8a3f9631696a0b50fab27922214a1d158577f31))
* **specs:** archive frontend view model boundary change ([5cc42f3](https://git.personal.ksv741.ru/WealthLedger/specs/commit/5cc42f30cab997a6d0889c124279d06d64fc8e07))
* **specs:** archive improved ledger event rows ([9a8687f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/9a8687feda24edcc6ee6412313a844afce50f5eb))
* **specs:** archive income analytics change ([9ac04cd](https://git.personal.ksv741.ru/WealthLedger/specs/commit/9ac04cdb44cacdad51b60e157a0d8a2f98e3a16a))
* **specs:** archive ledger-entry-type-label change and sync main spec ([37c0133](https://git.personal.ksv741.ru/WealthLedger/specs/commit/37c0133e8baf15959a0c38e45e7b7ac320b8084f))
* **specs:** archive local verification remediation ([72444db](https://git.personal.ksv741.ru/WealthLedger/specs/commit/72444dbbb11ae267e349adbf35256bd7c5be1489))
* **specs:** archive OpenSpec resilience change ([779a1e1](https://git.personal.ksv741.ru/WealthLedger/specs/commit/779a1e1305f8c209a4f98fae7f87718e48d61b37))
* **specs:** archive session workflow changes ([216956e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/216956e0c6868c75bce8390d36e90685922b2c3b))
* **specs:** archive stock card polish ([cf6bc17](https://git.personal.ksv741.ru/WealthLedger/specs/commit/cf6bc1761140f06f44ea29e2eee1a2b24d075e2a))
* **specs:** archive stock ticker change ([329f0a8](https://git.personal.ksv741.ru/WealthLedger/specs/commit/329f0a86a46b1358f83975714b0c378ebf795e7e))
* **specs:** archive T-Bank cache boundary change ([0386b6b](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0386b6b26fa06014182736d1773eb2e48c0293dc))
* **specs:** archive verification simplification change ([15ce5b5](https://git.personal.ksv741.ru/WealthLedger/specs/commit/15ce5b586da20ec2cf441a59547da5257c316b78))
* **specs:** drop archived optimize change ([2c9d163](https://git.personal.ksv741.ru/WealthLedger/specs/commit/2c9d163d396e7a770bfa0be33bf7fd5e1c2860aa))
* **specs:** harden OpenSpec authority lifecycle ([51344d4](https://git.personal.ksv741.ru/WealthLedger/specs/commit/51344d498808be09bb65b5c255fc4393103d5d0d))
* **specs:** mark remediation workflow complete ([37ba133](https://git.personal.ksv741.ru/WealthLedger/specs/commit/37ba133464140925cdc72cdb745fa7e34adb041c))
* **specs:** plan bounded SDD workflow ([50a6721](https://git.personal.ksv741.ru/WealthLedger/specs/commit/50a6721a6037677d97b1650523b4ada40be9668f))
* **specs:** plan frontend query remediation ([bce16ac](https://git.personal.ksv741.ru/WealthLedger/specs/commit/bce16ac3d6638fc8a5e5ab60c8844fde27453eb7))
* **specs:** record frontend remediation progress ([ca7ceed](https://git.personal.ksv741.ru/WealthLedger/specs/commit/ca7ceedcc307319d058516c7c1b0c8c710e893cd))
* **specs:** remove runtime OpenAPI gate ([05e314c](https://git.personal.ksv741.ru/WealthLedger/specs/commit/05e314cebbcb697938d5ca1ff6e1eb226bc73963))
* **specs:** start token-efficient agent pilot ([0b48b50](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0b48b50f4a0d098e42fea292807a96bcd419e212))
* **specs:** version audit snapshot in docs ([ead4834](https://git.personal.ksv741.ru/WealthLedger/specs/commit/ead48345de163870bd9f6e2cdd0f0ac9b3862dce))
* update system documentation references ([50658ea](https://git.personal.ksv741.ru/WealthLedger/specs/commit/50658ea8be85568995cc2b71568f9779452424b6))
### Specifications
* archive compact portfolio totals ([d5c1825](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d5c18258c471afe80306161e5e17b59b04667190))
* archive income page simplification with evidence ([fbb7193](https://git.personal.ksv741.ru/WealthLedger/specs/commit/fbb7193e900534728a4ac1256cc1f76c0ab88eb9))
* archive loading skeleton change ([1d934e0](https://git.personal.ksv741.ru/WealthLedger/specs/commit/1d934e01d7b1ccbd2717d11e1440b6f00dcda47c))
* archive local design token simplification ([19a658e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/19a658e31849083ece7a1f601baba67769dabf1a))
* archive portable design token delivery ([ca709b5](https://git.personal.ksv741.ru/WealthLedger/specs/commit/ca709b5ab9ef380f408dcd6d5bc2f5f0f265d144))
* define portable design token source ([e637a07](https://git.personal.ksv741.ru/WealthLedger/specs/commit/e637a07e6b2b1b6d5372255dc9899fe771ac08df))
* deliver compact portfolio totals package ([b06a35b](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b06a35bbaab83700f3435b50a559614860e5c07c))
* record approved income page simplification ([4c905c1](https://git.personal.ksv741.ru/WealthLedger/specs/commit/4c905c143368d799c142dbbce3a97d74108d6d28))
* remove income analytics period limit ([0608d4d](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0608d4dc23576c32875e3579c8fedeca92db32e7))
* simplify local design token validation contract ([f171b8b](https://git.personal.ksv741.ru/WealthLedger/specs/commit/f171b8bb4642dcaf0c9b4db8354eb6b8aaa4ea9d))
### Refactoring
* refactor future event list ([601a498](https://git.personal.ksv741.ru/WealthLedger/specs/commit/601a4989913fe5b0dbe8c8f612702012d56a7099))
### Maintenance
* configure releases and develop workflow ([3c7c646](https://git.personal.ksv741.ru/WealthLedger/specs/commit/3c7c646f1386ab0f5c1f7604da87f3933cb10afb))
* harden SDD governance workflow ([0006aea](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0006aea56207ad2192c678ced562feabf1a21c45))
* remove agent projection authority ([6719160](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6719160c13d1fba80b395a96b3389f86bfed87aa))
* **specs:** finish verification cleanup ([b6c141f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b6c141f4f3b5d95dbfcbd921959db910e8d1db66))
* use native OpenSpec validation ([2faf4eb](https://git.personal.ksv741.ru/WealthLedger/specs/commit/2faf4eb8f35be99c99b9711634a9dc4d9c083c07))
+81 -4
View File
@@ -1,21 +1,98 @@
# WealthLedger Specs # WealthLedger Specs
Этот репозиторий — единственный versioned SDD authority рабочей области. Canonical OpenSpec store `wealth-ledger-specs` расположен в `openspec/` (`specs/openspec` от workspace), новые changes используют project-local schema `wealth-ledger-sdd`. Этот репозиторий — единственный versioned SDD authority рабочей области. Canonical OpenSpec store `wealth-ledger-specs` расположен в `openspec/` (`specs/openspec` от workspace). Новые changes используют `wealth-ledger-sdd-v2`; существующие active changes с `schema: wealth-ledger-sdd` завершаются по legacy schema без автоматической миграции.
## Начало работы ## Начало работы
Перед созданием change прочитайте [engineering governance](../docs/development/engineering-governance.md) и [OpenSpec lifecycle](../docs/development/openspec-authority-and-lifecycle.md). Proposal, Delta Specs, Design и Tasks создаются последовательно по одному после approval Change Owner. Для любой команды, читающей или изменяющей change, явно передавайте `--store wealth-ledger-specs`. OpenSpec применяется после явного решения Change Owner согласно [AGENTS](AGENTS.md). Для команд, читающих или изменяющих change, явно передавайте `--store wealth-ledger-specs`. Структурная готовность CLI не означает approval: перед каждым downstream artifact нужно явное решение Change Owner.
Design должен оценить `frontend`, `backend`, `specs`, `docs` в Delivery Map и при обязательном trigger включить Solution Assessment. Docs описывает as-built state и не становится параллельным store требований. | Change | Lifecycle |
| --- | --- |
| Active `schema: wealth-ledger-sdd` | Proposal → Delta Specs → Design → Tasks → Apply; действующие approvals сохраняются. Legacy Design оценивает `frontend`, `backend`, `specs`, `docs` в Delivery Map. |
| New `schema: wealth-ledger-sdd-v2` | Proposal → Delta Specs → System Analysis → Design + QA Analysis → Docs Impact → Tasks → Apply. Design и QA Analysis готовы после System Analysis; Docs Impact ждёт обоих. |
Для changes без изменения observable behavior используется `skip_specs: true`. В v2 System Analysis/QA Analysis/Docs Impact всё равно фиксируют только применимое содержание и обоснованное N/A, а не фиктивные contracts или tests. `specs/openspec/specs/` хранит нормативное поведение, `docs/` — текущий AS-IS, backend-generated OpenAPI — текущий executable HTTP contract. Target contracts остаются в change до архива; docs обновляются только по согласованному Docs Impact через `[docs]` tasks. После завершения последнего legacy change эту переходную заметку можно удалить отдельным изменением.
## Проверка ## Проверка
Для native validation из этого репозитория: Для native validation из этого репозитория:
```bash ```bash
npm run check:openspec
openspec schema validate wealth-ledger-sdd --verbose openspec schema validate wealth-ledger-sdd --verbose
openspec schema validate wealth-ledger-sdd-v2 --verbose
openspec status --change <change> --store wealth-ledger-specs
openspec validate <change> --type change --strict --store wealth-ledger-specs openspec validate <change> --type change --strict --store wealth-ledger-specs
openspec validate --specs --strict --store wealth-ledger-specs openspec validate --specs --strict --store wealth-ledger-specs
``` ```
Structural validation не заменяет semantic verification и independent read-only review перед archive. Audit-derived работа маршрутизируется через [live remediation registry](../docs/development/audit-remediation.md). OpenSpec CLI ставится глобально и не закрепляется в `package.json`: его транзитивная
зависимость `braces` имеет advisory без исправления в 3.x. Ожидаемая версия хранится
в `openspec/.cli-version`; `npm run check:openspec` проверяет Node из `.nvmrc`, версию
CLI и доступность store, и при расхождении выводит команду исправления. Structural
validation не заменяет semantic verification и independent read-only review перед archive.
Node задаётся собственной `.nvmrc`; после `nvm use --silent` выполните `npm ci`.
`npm run verify` проверяет diff. OpenSpec validation остаётся отдельной проверкой
при изменениях требований. История релизов — [CHANGELOG](CHANGELOG.md).
## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в
`develop`; общие правила — в Engineering Workflow по ссылке из AGENTS.
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
```text
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
```
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
без нового bump; до merge исправь и проверь существующий кандидат без
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
defaultIgnores: false,
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
"type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]],
"references-empty": [2, "never"],
},
};
+1 -1
View File
@@ -1 +1 @@
1.10.0 1.13.1
@@ -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

@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-09-08
@@ -0,0 +1,434 @@
# Completion Evidence
## Delivery и authorization
Change `simplify-local-design-tokens`, store `wealth-ledger-specs`, 2026-09-08.
Владелец явно согласовал удаление избыточной runtime-схемы и её тестов с
сохранением компактной проверки адаптера: «да, давай избавимся от избыточной
сложности». Сохраняется разрешение беседы на coordinated planning, implementation,
docs и completion без sdd-session, session records и промежуточных approval stops.
Отдельные artifact approvals не выдумываются. Tasks выполнены 3/3.
| Area | Status / delivered outcome | Delivery commit |
| --- | --- | --- |
| frontend | affected: direct JSON adapter, два tests в theme.test.ts, ownership docs; net −301 строка | 8b29cdfd98790cb3316f1db0f8933c8b6bc4b335 |
| backend | N/A: API/auth/storage и финансовые вычисления не меняются | N/A |
| specs | affected: trusted local source contract и planning | f171b8bb4642dcaf0c9b4db8354eb6b8aaa4ea9d |
| docs | affected: as-built adaptation и пределы проверки | b0e747fdee2f726f18a4222f4bfc249c8f6d845b |
`runtime-openapi`: N/A; ни одна сторона API contract не меняется.
JSON bytes, public exports, provider wiring, CSS и dependencies неизменны.
После commits и frontend hooks все 303 reviewed entries сохранили binding.
Completion commit сообщается отдельно от evidence во избежание self-reference.
## Planning binding
Design UI/UX applicability N/A: внешний вид и поведение не меняются,
новый visual package, attachments и immutable reuse inputs отсутствуют.
Canonical fingerprints `wealth-ledger-input-v1`; только approval Tasks
нормализуют checked prefixes. Review/PASS используют raw Tasks bytes.
| Artifact | Fingerprint |
| --- | --- |
| Proposal | `sha256:7e5df002c15fb57ca7c771cdfdca7c71f36b341008659fbfc2e792e44e74e036` |
| Delta Specs | `sha256:e0b18a6a04b49361691f4a67df557bc049b94cf38a78a37a3d45b9a2c1e300a7` |
| Design | `sha256:c0d00f75623f3fa569478e5f1fc43a5bee527d97ba12e2dc4ca09eab89d378ac` |
| Tasks | `sha256:3d8a5f3df2ada9379a79b86c20bc11b80cc65e1dec7704d455aa50214aaf51f5` |
## Observed verification
Перед Node-командами использованы `source ~/.nvm/nvm.sh` и `nvm use --silent`
в affected repository; specs использует frontend .nvmrc до перехода в specs.
Node 22 и существующие lockfiles. Неизменившиеся gates повторно не запускались.
| Cwd | Exact command | Observed outcome |
| --- | --- | --- |
| frontend | `npm ci` | exit 0, 499 packages |
| frontend | `npm run test:run -- src/shared/theme/tokens.test.ts` | baseline exit 0, 16 tests |
| frontend | `npm run verify` | final exit 0: OpenAPI snapshot, strict TS, Biome, tests/coverage, Knip, deps, deps:storybook, app и catalog builds |
| frontend | `npm run test:coverage` (в verify) | 25 files / 112 tests PASS; statements 89.85%, branches 84.5%, functions 88.08%, lines 91.58% |
| frontend | `npm run deps` (в verify и commit hook) | exit 0, 188 modules / 705 dependencies, no violations |
| frontend | `npm run deps:storybook` (в verify) | exit 0, 189 modules / 670 dependencies, no violations |
| docs | `npm ci` | exit 0, 86 packages |
| docs | `npm run verify` | exit 0, 44 Markdown files / 0 issues, diff check |
| specs | `openspec schema validate wealth-ledger-sdd --verbose` | exit 0 |
| specs | `openspec validate simplify-local-design-tokens --type change --strict --store wealth-ledger-specs` | exit 0, в том числе после проверки всех Tasks |
| specs | `openspec validate --specs --strict --store wealth-ledger-specs` | exit 0, 25 main specs до sync |
| workspace | affected local Markdown link resolution | все ссылки существуют, включая новый theme.test.ts |
| workspace | `python3 /tmp/wl-simplify-fingerprint.py` | после delivery commits reviewed binding неизменен |
Первый implementation verify обнаружил типовой индекс в Foundations, который
раньше скрывался за Record из Zod: обход переключён на JSON entries без изменения
содержимого. Полный gate повторён после исправления. Build сохраняет обычное
неблокирующее chunk-size warning. Gate exclusions и coverage floors не менялись.
## Browser proof
Playwright Chromium. На `http://127.0.0.1:5173/login` до/после видны «Вход»,
Email/Пароль, submit и ссылка регистрации. Сравнены theme, colorRoles и computed
CSS maps девяти элементов — полностью равны. Это smoke/preservation без submit
и authentication. Raw captures `/tmp/wl-simplify-before.json` и
`/tmp/wl-simplify-after.json` использованы reviewer как временное evidence.
Storybook из свежего build: `npm run preview:catalog -- --host 127.0.0.1 --port 6007`.
Sandbox EPERM при bind разрешён повторным запуском с escalation. Manager содержит
Design System и Product. На ширине 375px отрендерились Foundations Colors,
Typography, Layout, Button Primary, Product PositionCard Bond Loss и
PortfolioRefreshState Refresh Error: все 6 без горизонтального overflow и
pageerrors. Fetch/XHR только к catalog index.json; application API/auth нет.
Enter на retry сохраняет focus. Наличие action callback отдельно не заявляется.
Старый dev server потерял manager bundles после npm ci. Перезапуск собственной
сессии командой `npm run dev:catalog -- --host 127.0.0.1` восстановил 6006;
в браузере повторно подтверждены оба блока и заголовок Colors. Временный preview
6007 остановлен. Индекс и story IDs не менялись; новые stories не добавлялись.
Полный visual regression всех маршрутов или новый продуктовый пилот не заявлены.
## Independent read-only review
Reviewer `/root/simplify_tokens_review`: **PASS**, Critical/Important findings нет.
Прочитаны artifacts, diff, docs и актуальные gate/browser results. Независимо
сверены baseline/after JSON, сохранение Purpose и unrelated requirement/scenarios,
локальные ссылки. Reviewer не редактировал source/planning/Tasks.
Review выявил пропуск ignored frontend/.env в inventory. Включены все существующие
.env* inputs (значения не раскрыты); mtime .env предшествует gates. После уточнения
scope reviewer независимо re-expanded inventory и пересчитал каждый entry своей
реализацией; bytes/modes/missing entries, subsets и отсутствие conflicts совпали.
Новой реализации не требовалось, источник и gates не изменились.
PASS: `sha256:21239824e79f24738c39448a304c4b47e34bad59c443c1d29d80b373b1041378`.
| Gate scope | Entries | Fingerprint |
| --- | --- | --- |
| frontend | 202 | `sha256:ec148234ae35ab988012b988b76b50ef9202d879b480e2a621e1ec4a5196aa39` |
| docs | 62 | `sha256:d06e53ee6f5f0c82d47423b2dd07d06e33ae10e8ba1b8e30be6da0d5e9f0d63d` |
| specs | 38 | `sha256:6c45988ff6302545b81eda7402caf2e702b98b6752ee8fbd10aeb00bf8546d4c` |
## Semantic sync
Current native status определил только `specs/frontend-design-system/spec.md`.
Native specs instructions прочитаны. MODIFIED сохраняет все существующие scenario
identities; у «Исходные токены некорректны» меняется гарантия согласно запросу
владельца. Первый структурный проход отклонил удаление identity, и delta исправлена
с сохранением heading и явным новым смыслом, без обхода validator. Purpose,
остальные scenarios и requirement об общих компонентах сохранены. Ожидаемый diff
показан до archive; expected main сохранён для exact post-sync comparison.
Historical archives, другие changes/state и references вне mutations.
## Reviewed input inventory
Полные frontend/docs tracked и nonignored untracked inputs; frontend .env*;
main specs, local schemas, current change, config и применимые AGENTS.
Исключены собственное Completion Evidence, другие changes и historical archives,
unrelated specs tooling, node_modules/build outputs. Runtime задан lockfiles,
npm ci и digest environment inputs; содержимое .env не скопировано.
Deleted files сохранены как missing. Entry fields отсортированы по UTF-8 path,
kind/mode/raw SHA-256 образуют NUL-delimited `wealth-ledger-input-v1` digest.
Expected archive/sync и собственное evidence — отдельные completion outputs.
```text
file 100644 3e5ed52ca50a0424fb967a92afeaca6e162f3c16e01d0fcb352e48362235cd7f AGENTS.md
file 100644 4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97 docs/.gitignore
file 100644 be63447d1d31bcedba10e009b12bd9bab07d5918bc8de8093865c74a54e7fe78 docs/.markdownlint-cli2.jsonc
file 100644 f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 docs/.nvmrc
file 100644 a934307476c518f447a91c83e139b17860da32608cee69e210cb4ec985f5d9d1 docs/AGENTS.md
file 100644 e43d6d771bebd53bc6864646cb13b5f7501d610edac17fe13b2e7ac945613757 docs/adr/001-prisma-sqlite-local-mvp.md
file 100644 13b55e00d9e362d4764ef55976bb250ea78c0931a1da8f0453baaa091b5a8bd2 docs/adr/002-nest-generated-openapi-contract.md
file 100644 913388ffa0dec5dabed83e72805aa1074390ea09e600c35db744d7dd10385ed6 docs/adr/003-tbank-server-side-grpc-boundary.md
file 100644 7345db35f23bf7436a05749a7223191cdd43a0490b80252d57f8aed52cf5eb81 docs/adr/004-encrypted-per-user-tbank-token.md
file 100644 d176fe9d114c3f3cfef5437ad109fc4c0763996df13811f82d94db375ed81416 docs/adr/005-tbank-upstream-resilience.md
file 100644 ad17b1ca13be43b476f47b4395eff0639162928a761fa0c540ba8d9f2de23661 docs/adr/006-auth-session-policy.md
file 100644 deb1c7cff757e6f500d8ba1a6982baaeb79d8fdf4aa0127cbc32d8530df86665 docs/adr/007-react-query-fetch-policy.md
file 100644 3420b3fbe6893b08390366a418fed0e7024a5e8566c037b6d3e9158bf8fc3848 docs/adr/008-pino-backend-logging.md
file 100644 0eb9ec3f94fe5cd7eddf601d8dd98b1ea1c482c58f4af039ed0d36d9219d265f docs/adr/009-concurrent-refresh-rotation-policy.md
file 100644 b53109e4c6ccf104a8ef87d8f8f007cf8291de6cf99351201741df390e89dee1 docs/adr/010-native-openspec-sdd-and-local-orchestration.md
file 100644 87397b932d073691809e8c7d16c0979e6251fe8babdd8c3545a951feef0dc13a docs/adr/README.md
file 100644 e84fba694c45cb6d5c747e7b44a4678280794b828c33a7ce0c3b6dc75f38b614 docs/architecture/README.md
file 100644 246278c09dfe2a53cbece93b0db1ae1ab44f08cfa87167da2e5d98f37b39f8b1 docs/architecture/domain-glossary.md
file 100644 6b9b0b24f3b22a0e78f6f3315045af33d101a31309eab9063f5f099ea002a921 docs/architecture/system-overview.md
file 100644 b38ec6ac31d18cf6a4ceb8bf5c176640f8909c8d708884eb1d2106e503423321 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/README.md
file 100644 a2cc3b5ebf4c4ced326d4ca8cad1da59c725ae0538c5f5df0b885203d03177f4 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/deepseek-audit.md
file 100644 528952869ceeef2502a90baadb9d4802aada1fbf342730acc35535f3adbc77e5 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/gemini-audit.md
file 100644 a57789cb6f6c7f9415ba45a279d347ee8e5749063b62d2d9d8a4c214938d8136 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/grok-audit.md
file 100644 67f5d725f548c221c824a90ee0254f2d4805479cd3069563c3732e844fd625b7 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/kimi-audit.md
file 100644 74b41a6c1f8302da649e00c5bf83b34804a604f0b0a563261fb3e5ac6f30abe5 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/qwen-audit.md
file 100644 af06d4060e43d91ba2aac2ca453f0cea6a226fc0ccff66d68412c3d646018979 docs/audits/2026-08-26-wealth-ledger-sdd-v2-review/zai-audit.md
file 100644 7d9949ff660b77df66c6c1a863681dcd0ee6af00b131394ffa9c3154034c52a6 docs/audits/2026-08-26-wealth-ledger-sdd/README.md
file 100644 2d50f4c195507ceb079d69489c03f1ea8a81185e2594dd51ff6a293ab8198db8 docs/audits/2026-08-26-wealth-ledger-sdd/deepseek-result.md
file 100644 af95a4c208c993621a6c15102a1e26684eee6c2966b166f6ba23b600d0e33696 docs/audits/2026-08-26-wealth-ledger-sdd/gemini-result.md
file 100644 31d423903cde2d7c1feb711cc625c77864964bb66cfd0cacb734c5c6bbdddd17 docs/audits/2026-08-26-wealth-ledger-sdd/kimi-result.md
file 100644 1b6582efe55b7044d2bebc9c1a146df1963e4103c5517c259ccf617d6d2b4f14 docs/audits/2026-08-26-wealth-ledger-sdd/qwen-result.md
file 100644 0bdb7785087c8d1b6fe532d578804aa498bfc90935623d79c1003cd1899d5bad docs/audits/2026-08-26-wealth-ledger-sdd/zai-result.md
file 100644 49054cc1df4206a105a2d192457012097fc1fe856d68b95dec7c497b8138ce31 docs/audits/2026-08-frontend-repository-audits/README.md
file 100644 6b7968ebeb7eac1017e2c75e7e7888486d8a232bed7730f760960d77c5343e24 docs/audits/2026-08-frontend-repository-audits/frontend-repository-audit-2026-08-28.md
file 100644 1071171cfc987113ea209383c687b0b54932e5a390726ca62650b0de90a722b6 docs/audits/2026-08-frontend-repository-audits/frontend-repository-audit.md
file 100644 7b5de5c82951f70b8ec7431468ed5cffee3bcf98ba09b029d327a8bb72c1fc36 docs/audits/2026-08-frontend-repository-audits/frontend-structural-maintainability-audit.md
file 100644 84d665f0cbe510f379e4473750c962ea1a0e2aa24161c86ef6f742ba66227f4b docs/audits/README.md
file 100644 3e5829193d0854156bcfac1214e7bfca60c6e8d8ce8db9154fa807a431c636c8 docs/audits/audit-13-08-2026.md
file 100644 27e2cc3fc3ad3d3f2cdc20728d6b812254d923a2f0e1e45be2f14490ece0dabf docs/audits/audit-20-08-2026.md
file 100644 163085a69de87ba1dd28e3f82118786167f9c0489ea9dd2695cf9ab2e8a68bac docs/audits/audit-24-08-2026.md
file 100644 e2d805d48dc793468bcc2f5126ab72cdfcfec6d1ec8d5916897dfe432192a73d docs/audits/codex-sessions-token-audit-2026-08-26.md
file 100644 08b06c2ad2c7e6ad32320a4cbc42e251a612ac82900ef74bbaecf376b4b1a2f9 docs/development/README.md
file 100644 dae11d6a0dccc381d671173162740e74908c57414b4db07e12f79f316a13562a docs/development/agent-token-efficiency.md
file 100644 be6a892e8f97dca5549b6508281f52d529946988408035ac8ce1801994423ce4 docs/development/audit-remediation.md
file 100644 5c87fa0c931f56efddc0fa887627e55ec6728467c61fbf1394447f78cef9896d docs/development/backend-workflow.md
file 100644 5c1f3fb3c72613c25c8591355a56cdd10749295d22425df6dd7f36b27fbc2434 docs/development/completion-evidence.md
file 100644 2e82bb27e2c7dde5f31315f731c8cd97258570608dcc8518c9679688c8145eec docs/development/design-system.md
file 100644 643822346cfa50a30e79d3bcfdd649a7902a43da7a8a31ce773e984fb179fa5c docs/development/engineering-governance.md
file 100644 5eed875b21f69812ff92660ac3199f8b2a9e55427092f1e9713d8c81501feef7 docs/development/frontend-workflow.md
file 100644 ddf26cabeecc70b512fadc6c24958bc33c408cf91bbb45d49f9483a1851e4c31 docs/development/openspec-authority-and-lifecycle.md
file 100644 499989c73752b75e7ba92c268c8eb5fdf26ea1e709467c63173a157a63b22014 docs/development/runtime-openapi-verification.md
file 100644 4157bc32721a7aa76abbf45f69a250b9512c74ef4de2ac6186e13e72398cb8be docs/development/ui-catalog.md
file 100644 2a38cae1914bcf930596cb893890e78c1a65faa91543564422fb6b38fa405bea docs/features/README.md
file 100644 68d987a914c0a37e7e39fae167be0aad25c8f33552999a0f9d0eb35776bccdfe docs/features/authentication.md
file 100644 bf7b7a0f8ce554efa762574c45608938aa8fb51db4d01dc9a6c3dbc13f63c5f2 docs/features/brokerage-account-income-analytics.md
file 100644 b2d10d9da77e6e96c511b2f4bb55dfa87da4f530c4c9666a49b93b835b4647d1 docs/features/brokerage-account-ledger.md
file 100644 f041f612a4cdf784ce75e11fcea673f673bcf2cfe5b63602277a0c976935b7e2 docs/features/brokerage-account-upcoming-events.md
file 100644 7e32238f1addf3e229b210ecf35f112d742e40dcf9bda42e545f2961250309ea docs/features/brokerage-accounts.md
file 100644 fffcfa56a1d9d073db603d4e42e739b47ba2066acca51e994c1b7b7eaac43f27 docs/features/frontend-ui-resilience.md
file 100644 8b590274bdc9ccb06cc8d1367d42b64d6758fd43fabf4c79fb2aef321d1f48ec docs/features/portfolio-view.md
file 100644 0846ccfa3c2a821fea374c3bebba0038d449a7e588fcd6e4871a9d7d04668702 docs/features/tbank-connection.md
file 100644 e71ed9ea9040268cbb5f4002bf7a60d85f1808fc3eb5288305eae35559088d55 docs/package-lock.json
file 100644 f183b2f571b7ff73af7a071d49be568f3616c8e84b91127358ed679778227973 docs/package.json
file 100644 71153d43d010a50437ea3c54c34fd26c55470399219021918428a0a7178e9213 frontend/.env
file 100644 71153d43d010a50437ea3c54c34fd26c55470399219021918428a0a7178e9213 frontend/.env.example
file 100644 1820d069d49f5ea71159a8d50e55db1d02b5010825e1da111386a0a5be01eaed frontend/.gitignore
file 100755 ba793c27e4dd0c45841bb290fb8771ef92611eebfd7b8fde2f15394b2baa7dee frontend/.husky/commit-msg
file 100755 3166f419c8d677f3556b87d2c1a502664cfebe9c7d2149ac3699359c30e2409d frontend/.husky/pre-commit
file 100644 f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 frontend/.nvmrc
file 100644 0d30b6034cab756bb1beb1cbd720c8fe73026278aeb0100f17e0133582547694 frontend/.storybook/dependency-cruiser.cjs
file 100644 0ee75a2839e02389cdfe113cafaf61b4ceae864656a5bf0e603c5b9de096d61c frontend/.storybook/main.ts
file 100644 05434fa7b625bf0ecb58c0d81684b30055c6c6ee6cb2b42b38591e7a33781ba3 frontend/.storybook/preview.tsx
file 100644 cacf1dc8f6dad83c1c35212ed806204eb28faf9bc8d7217cb2da372411008cc7 frontend/AGENTS.md
file 100644 ffdaeb7f2a05d3f842f102766f7471fd66c470728884410bc6d90a637b1721a5 frontend/README.md
file 100644 d42ba2c972d01e98cb03a474c50afed340e426cb6ff98bcf7495eacaf8372f0f frontend/biome.json
file 100644 eda2b3989c752320cce88af66b8d488ca4c6f8c417fcd3b9f4e8fd54fbda9284 frontend/commitlint.config.cjs
file 100644 c931344af98938f3e4df01abb0db759b3e47d16084fc7c198edff2a846f5021d frontend/dependency-cruiser.cjs
file 100644 9bc5e3fa848a49f7990c2d2727381d47c21ec9d4ea73a7223d5ae3c93be14f88 frontend/docs/API-LAYER.md
file 100644 75fb467b458a40e49d6a837cbc22809838f15e1010e8f6c7d671414b5e833b80 frontend/docs/CONVENTIONS.md
file 100644 4a4f69aab159b4f6e68b0d7d1e5b997691db3c0dc77b8ea367eeb4354c3b3986 frontend/docs/README.md
file 100644 f6fdec18b0b86baad1009b40d431d9d5c66b5aee3cd2e676e9836d52a367c50a frontend/docs/STRUCTURE.md
file 100644 85dd9e70440f2d395eee30d9790ba2e0eabba69c5571578dad9c475144d478ea frontend/docs/TESTING.md
file 100644 13e90ca029e66456a066d67ce8338294ca540bf2fd368309c59571ef23d2588c frontend/docs/UI-PATTERN.md
file 100644 71a8cff454e6f9f761a0a934d3cd58f8fa72d1c6281e53ae22b38711b8730288 frontend/index.html
file 100644 a195a4ae649c0d6998ae34328481f2b7426c658c3dcfb40ff69c11bb7cceea08 frontend/knip.json
file 100644 341a036a0fce7efd6f507e09893b6613ee3513d70b6cb4cb3733e14ecb6ce1d5 frontend/package-lock.json
file 100644 a375786c0da27d74347539ed395135f540bb94d8a0ff40a5bc425ba66f87996e frontend/package.json
file 100644 a63748301b50343c1bf3729564d40e36633ce8847987c280ac2f5a3646841b0d frontend/public/favicon.svg
file 100644 8a06bf817b3cb62c3c9e7585dd5975d66c6be15e63d565daea59d4cac40a4a39 frontend/public/icons.svg
file 100644 e03760ce70a5e7fee38f2862ddc559c31ab18423b475c03619b7f9854906c937 frontend/scripts/check-openapi.mjs
file 100644 916c2f50916e33565bd7d55e4b751ba227bc516de53704d59df58b5efe0727c9 frontend/src/app/index.ts
file 100644 270e09cb6081ed03f60e64df61aae4614370ae1a85577b9ea73b40473854430f frontend/src/app/routes/AppRoutes.test.tsx
file 100644 2fa3448504fd351d53856f80e82ab4f5e6f7616c2ee51fca765602e08302efd9 frontend/src/app/routes/AppRoutes.tsx
file 100644 e9f05e96f393e161b039d342440b64d14f0656431a1712e043735b6b855294a1 frontend/src/app/routes/ProtectedLayout.tsx
file 100644 cb21d17cc519ecd294f49bf7e81758b95012a58eb3874497bf8deaefa8ca6d1a frontend/src/app/routes/RouteErrorBoundary.test.tsx
file 100644 45d27f3afda5c382751f6e3cc8547a1c4562361eb8780ddb7e577256ce865d0b frontend/src/app/routes/RouteErrorBoundary.tsx
file 100644 187b0c0c3a828d8828d8b234d5ba722fbbdc246ed8c852548f323ce5e52e455d frontend/src/app/routes/RouteFallback.tsx
file 100644 0ece306b18df665427bf00794ffc52112f3893321edd4c01cb2cf709911b6a34 frontend/src/app/routes/RouteGuards.tsx
file 100644 5349945f286313fd06df177d43d226b727646056fbfea4ab737ac5301790eac7 frontend/src/app/routes/index.ts
file 100644 c38291e83facd7105c710e8e1827d9d8adc01c98943cfb3449bea6e90e5f5826 frontend/src/app/ui/AccountBreadcrumbs.tsx
file 100644 2cffedebe0b98d60bfe8d1d26752dcd3ee1b0da3a4b575d1bdb03471dff0030e frontend/src/app/ui/App.tsx
file 100644 5ee3c223a10327120bed54a71b9d22023d8597e9db347e9e91f5c9276a33c7da frontend/src/app/ui/AppBreadcrumbs.module.css
file 100644 a881a844060e88f2d0f062ff232160eb35980587a7e22794186183acddfcde2c frontend/src/app/ui/AppBreadcrumbs.test.tsx
file 100644 94335087a5c175c313b072801536480e3b48057bf19e00213d812d06198010b7 frontend/src/app/ui/AppBreadcrumbs.tsx
file 100644 ab0ce496eb8d73c015719f36f0c66e98a9d3da4c56cdfdd5fd69848dc40c2269 frontend/src/app/ui/BreadcrumbTrail.tsx
file 100644 77fd6a747b349594d3c4145bc0e2bbb3e90d70a5b7805e94557e3a7d5136200f frontend/src/app/ui/index.ts
file 100644 881ffbcaafc212e49addad08846a5b82761355fa20624253af3477ba33262c5c frontend/src/assets/hero.png
file 100644 35ef61ed53b323ae94a16a8ec659b3d0af3880698791133f23b084085ab1c2e5 frontend/src/assets/react.svg
file 100644 5be21acd42eb7b896e517f4e0f0f11eb5c5d9e54fbbcebe9453f033008fcca6f frontend/src/assets/vite.svg
file 100644 8e926e8984f57c554a129b43a71ab32ad6964f7d42544084de52503808df807c frontend/src/entities/broker-operation/api/useBrokerageOperations.test.tsx
file 100644 940c1e6828af0a0111f44ea7b3e459e53e0fbae51147c4408b64503150655ed5 frontend/src/entities/broker-operation/api/useBrokerageOperations.ts
file 100644 4e09b8b326c418535e843d5ce6a40e781982e0309771f2c39662a81aaf35fa2e frontend/src/entities/broker-operation/index.ts
file 100644 c2f624d4159f6abef51a02ae891b4756f8d891219a2fa40f19371bcc769bbfbb frontend/src/entities/broker-operation/model/display.ts
file 100644 c20f2c6fc42362f1f5786a58b7ab81fb70d4a620438a8b2d1f85de1edd4f49cb frontend/src/entities/broker-operation/ui/LedgerEntryRow.module.css
file 100644 1b863f3e11e53b820135d8c8f74c1f51868528abcec866a3a0bb90dd0dd9bb7e frontend/src/entities/broker-operation/ui/LedgerEntryRow.tsx
file 100644 5547bba2b73d4362bf8a28c9542c552d27895176af85dc0e2815514dbc42f3f6 frontend/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx
file 100644 777dc47b9e5373d00d1e9aafeda16d903961332044a8a53bce75b27cb4e2e970 frontend/src/entities/brokerage-account/api/useBrokerageAccounts.ts
file 100644 c39922c507a2fd0b00555e6403258d453a44c3ff6d15bc5549664b49ff7d465f frontend/src/entities/brokerage-account/index.ts
file 100644 cf8ac5c71ed4880c4ecdda54d9654490d541497dad837398d2e768aeda09c5b7 frontend/src/entities/brokerage-account/model/display.ts
file 100644 1214d8657833a336190a037745c88e205d03162a2a5c6fea7dc4a7e89b8194b3 frontend/src/entities/brokerage-account/ui/BrokerageAccountList.module.css
file 100644 f901105c428c0c1149ff6ae6fc180d853036d6e5b5fa7d114f58f019a6f4a804 frontend/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx
file 100644 8b8c836509486010ea3d1e6103edcf16cb5845ed5b8b174fe76a6c7ab4487855 frontend/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx
file 100644 c77c41ca8984c36402b2ae23a178e7e3d2a6955e76fa0ddfd82e61054702a815 frontend/src/entities/brokerage-account/ui/BrokerageAccountList.tsx
file 100644 63211bf87150b004c58404d6a41806148eaddd945272ce7934ca88266d137896 frontend/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx
file 100644 8b0d8a3d3202aedd69efca839e777711b44d8c79b677cedf75b2a0941673c1d7 frontend/src/entities/brokerage-event/api/useBrokerageEvents.ts
file 100644 6e8a418ef85d3fa95f6a727297a70609f01160ef254ff8b4d1dffb0c16b4b9e9 frontend/src/entities/brokerage-event/index.ts
file 100644 e4f29cab3639aca0750913c364b01f5d876784f2abd19c98f94994f3a1b761d6 frontend/src/entities/income/api/useIncome.ts
file 100644 31be30a5f707316af8a3307bd86d38a4152a5601170f7defca637aca1b6b667d frontend/src/entities/income/index.ts
file 100644 3ca6f73af2e1bf44c88eec3590e7ffe072e617d4401cf7a23643ff8ef44782a2 frontend/src/entities/portfolio/api/usePortfolio.test.tsx
file 100644 db5edf155da5992f4f60e2909c2d9872b69add47a778a4a15808b1381bbbc21e frontend/src/entities/portfolio/api/usePortfolio.ts
file 100644 741bed6b52affbde608fa3809e153db54ce0e5368f61c86ecfd97b41f3173db7 frontend/src/entities/portfolio/index.ts
file 100644 bd7a0c75ca7aac94c99addc4a80150432958b966d20689f6858798460aa32bb6 frontend/src/entities/portfolio/model/display.test.ts
file 100644 05c82e80532566ad042d0c4a8e491cf853a6fe3a5a8a5ee1e64afa2cd21d9abc frontend/src/entities/portfolio/model/display.ts
file 100644 e753ea28bc12ee02b3f5c5c88e0135b0647cfc923f31c067585752b359d7dccb frontend/src/features/auth/index.ts
file 100644 124b93ecd38607425930067a6021f0a73c0b49d94c86693169df200c7f924d7d frontend/src/features/auth/model/auth.test.tsx
file 100644 3cabda4d2c863cd7ec4edc26e783086d3cded61dbcf600c396b8e248e6727111 frontend/src/features/auth/model/auth.tsx
file 100644 3a75a1cfcf0adff1ad15fcd8e030d63731dd6a8c3a8195ea3758b81d21434ca1 frontend/src/features/auth/model/validation.ts
file 100644 d45861d840614c9f3226ffcb82397feae7b63415aef47967ba609d4c830880ad frontend/src/features/auth/ui/AuthForm.module.css
file 100644 2ecce514e944a596158b4c7a9a62dc71e27035a2dad30d244c470f1acf27a43f frontend/src/features/auth/ui/AuthForm.test.tsx
file 100644 8324ca8b2435d72f9808869c7884b7e8ab3fe511141dd076472970815b1b9635 frontend/src/features/auth/ui/AuthForm.tsx
file 100644 0f0d56b173312701a8b0412bb8b6da3f098a867b131c4b23d442ecbc79061cf7 frontend/src/features/auth/ui/AuthPageLayout.module.css
file 100644 83111c2fb3517b80756f890ca3f7d5d9f6da5e5d9758b7ff5d840a979a8c083e frontend/src/features/auth/ui/AuthPageLayout.tsx
file 100644 8e4561241c21d14c87e4b9f5d413d8bbe0adeeb4204fb8b91b5b6b735aa443d3 frontend/src/features/auth/ui/LoginForm.tsx
file 100644 946ac85c6efb952092fac8a8f6a58c26ed27d9eebf57c609935f0d0517824e92 frontend/src/features/auth/ui/RegisterForm.tsx
file 100644 fb48585faea80f8e1f252e222a7256486518d767b66032d111deeededd3113ca frontend/src/features/tbank-connection/index.ts
file 100644 673636a6275565755a383cba89a29c42c61ade16fd499308103aa9d9cbb32d73 frontend/src/features/tbank-connection/model/connection-boundary.ts
file 100644 a1324a729d5a4a4545a5431c170e50c205df0fc7a17aef3af3e7acfedd263087 frontend/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx
file 100644 87b3e3d0c114166c6c9c5cde6a99bfdbd6b3a6ae342e744a0734fb6a7630870f frontend/src/features/tbank-connection/ui/TbankConnectionSection.tsx
file 100644 690d130a1ae2d4e49b1e05349ef70123a48baadee13f6778f058d3d02c439399 frontend/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx
file 100644 5095f80b8a698c78beff4f6ee6688a76d058929813475298976458df1ec33c68 frontend/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
file 100644 09fd1fecdc3b3261f0e48072ef42a75bbaa92100861b4e4564ceab27044d5095 frontend/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts
file 100644 ef66e1d01c4bcf752b1c77f4e27bd01f23afa5ce3527b5b13001fad1d03bacc0 frontend/src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx
file 100644 ba06f55346657614504488b64c12f11c2fcde3f7f407f627a53cd378f10ed362 frontend/src/main.tsx
file 100644 8ca150b0224671f8aedf2e9c51d5d8cf07a99aa88e8ad2ad631869a0852eb3c4 frontend/src/pages/account-events/index.ts
file 100644 092266dce3058a0888f809b0b2edfd91fb93d0412ae21409021352cbb31747b6 frontend/src/pages/account-events/ui/AccountEventsPage.test.tsx
file 100644 ef4a005d7cd955db6e9c8caa525593417dbc836587801118f07026d9108b71bf frontend/src/pages/account-events/ui/AccountEventsPage.tsx
file 100644 c73bc8df3b9a16703a853823864df62525eb0808f6cf4306d46763a16909b426 frontend/src/pages/account-events/ui/AccountEventsPage.vm.ts
file 100644 7e68b58167a4b94e36a53bd6afa46f02a15812bbcc2134f8d9db895b36140677 frontend/src/pages/account-events/ui/components/AccountEventRow.view.tsx
file 100644 d107a120c5b0b2fcb5148a9a5295de9e70ca1143d9aa463c052099d8e0beae23 frontend/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
file 100644 b592d310ef3f3f3903ec928ed80be8090ec03458b82df9595958d9ebe2fe24ce frontend/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx
file 100644 2231260ea36c10973d99b04aa669ea005b5a170f717110f8c2918d8b753028aa frontend/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx
file 100644 87c15175cff3ffd21ee5600ac7bc6778cb9a5d62787a067a3140825ad70296d7 frontend/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
file 100644 6e4320e2d46dd1f9205da955915d1dcd8c52ddfc99dc0eefd529ed473560a0ca frontend/src/pages/account-income/index.ts
file 100644 948c2c317aa93c8a15cf7f5221980b8063a8326bf70641e7c2403d23eb6e2df7 frontend/src/pages/account-income/ui/AccountIncomePage.module.css
file 100644 5844d4515ce3fd04cf3d283039d9c5e396f6b2742ee6f01e0312c55c25562dc0 frontend/src/pages/account-income/ui/AccountIncomePage.test.tsx
file 100644 7d96d2fe6ccc62bb29e82b71dd26c82de4c7a6cd783c24177ab6dab7df712b9b frontend/src/pages/account-income/ui/AccountIncomePage.tsx
file 100644 0c98d08ec0c2ed37fccc6c573293b97a0fa61a7d0202c07f7d80eb56a30d88f9 frontend/src/pages/account-income/ui/AccountIncomePage.vm.ts
file 100644 881bf2279bc644094096ba09c377e5d5f47fd8d18fca4316377e867d69c6b5b6 frontend/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx
file 100644 f961337393750024699e33eaeaf4f4223842a338a4c4dc7b609f2c61efd3c465 frontend/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx
file 100644 fb799544f998d4d9082e6decaa1e4e97784adc1196196bc5c85341a931dddb6d frontend/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx
file 100644 97bf6d52cc7289a9814bdbb8a3180db40d5c56728c641846d29a83092b4f8063 frontend/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx
file 100644 97a5cc4b73214a0785e9a73b0588ca2b9cb3b5df634e44320f643d0ae1f8b573 frontend/src/pages/account-income/ui/components/IncomeComparisons.view.tsx
file 100644 c825b843685692d9f22367a6b1d857026ea106f168fd6d3285b73d49dc87cd98 frontend/src/pages/account-income/ui/components/IncomeForecast.view.tsx
file 100644 b498969cec972dd25c738b0b2a5fab9fddfa2071fe403feba8b3defbe96a4e2f frontend/src/pages/account-income/ui/components/IncomeSummary.view.tsx
file 100644 b40e96330418d1143691d47632c097db9a3c964056660397faa72bd09283bb9a frontend/src/pages/account-income/ui/components/IncomeTrend.view.tsx
file 100644 52547737449ffd6c907d93f685698500ac63999a68f6ef2411d0abecde85e453 frontend/src/pages/account-operations/index.ts
file 100644 98d047e57b00cbdb22027e2f382f26c4fe77db07d59dd6c307f79ad03768e2af frontend/src/pages/account-operations/ui/AccountOperationsPage.test.tsx
file 100644 1429f228fe702a9e467270b8c86be761ad682f2b0e46f054772cb6850406eb64 frontend/src/pages/account-operations/ui/AccountOperationsPage.tsx
file 100644 8677eefcf98e2e48ca108224306d8f9d3fcdd3977cf4a91a0a571f1d459f6eaa frontend/src/pages/account-operations/ui/AccountOperationsPage.vm.ts
file 100644 694f220b6fbc3ef0bfd09478e40dead28efb4c0b474b2db3ae400c0ed106bb85 frontend/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx
file 100644 ceefca357d7bf19b0cd53715dd9700bd4e7343ab436950e4efeb6c90b87833cf frontend/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx
file 100644 1b5538a2d1c750b1b89d68ca208f857377dac21acaedee4a0b61da519f0a27e8 frontend/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx
file 100644 acb5e60d2da1d85cf7518a15778a710a1aa17e31859f0e4b6313121e2e711222 frontend/src/pages/account/index.ts
file 100644 adb8635ae3524b97355f18a235664373af718470edc62e036b36c1e75d6cacc2 frontend/src/pages/account/ui/AccountPage.module.css
file 100644 7f8e70fbe71345f7426d05b9f65090d3cd9241e49ef71ef77ca4a61a4e35fc3e frontend/src/pages/account/ui/AccountPage.test.tsx
file 100644 889a4ccc68936d880336524f419d959bac4360f4d34bcbae340a9bd6f28e746b frontend/src/pages/account/ui/AccountPage.tsx
file 100644 b1db7a7bb3c6aa9e07f97eae1e74d62ee7e29d878493b91533dc0ee4d9ba0df9 frontend/src/pages/account/ui/AccountPage.vm.test.tsx
file 100644 e90bb1f9993ded5071532ee34c8ee15c6b534fe112c503d2e6a212c3e63ad806 frontend/src/pages/account/ui/AccountPage.vm.ts
file 100644 e78ee3b110780be81ecbef6fdbf4931097c32434b5df82453f79cb0e291cfb8d frontend/src/pages/account/ui/components/AccountHeading.view.tsx
file 100644 d22cb260448fca772c985d2a29f565e83fad672f2d9b4b42fb92fbc5980d9532 frontend/src/pages/account/ui/components/AccountPageEmpty.view.tsx
file 100644 258ef783c093ab2640bfe3c312c27e52d74b9acd0402996a44b7f4ebb7c05960 frontend/src/pages/account/ui/components/AccountPageError.view.tsx
file 100644 1a8f7b88c2a5cbcada4443095aad386a181bc57911d6b80ebc593a7759b371bf frontend/src/pages/account/ui/components/AccountPageLoaded.view.tsx
file 100644 b19f2db05a01ca43f4992efde81d47a6d536459918ca56917054dfea9452cdf7 frontend/src/pages/account/ui/components/AccountPageLoading.stories.tsx
file 100644 981f088c47cbabfd57e9aa57290f27953fb66a284533941892dcbcac7b66af3c frontend/src/pages/account/ui/components/AccountPageLoading.view.tsx
file 100644 9c6480b81d4df06d111672a0cbe679bfe8dea5d8bdea7943850591034c6b46c9 frontend/src/pages/account/ui/components/CashBalances.view.tsx
file 100644 0fa5e2693c91a826af7eee7dae7700321f4f4fb1668baa1f176b210855ab6da2 frontend/src/pages/account/ui/components/IncomeSummary.view.tsx
file 100644 8bfd285a1b34557402ba03f5b2d6678e96831b2eba65122f877456da483d2a44 frontend/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx
file 100644 fa07c51edb7eb2d0180de2ecbe0522ea9621ee49b605dbf1e6517b6c5f7d5d8f frontend/src/pages/account/ui/components/PortfolioRefreshState.view.tsx
file 100644 3a059b529cc2fc4477d81d3eae83dfe746d39b7bd11b8cb0ee8f599237a19606 frontend/src/pages/account/ui/components/PortfolioTotals.view.tsx
file 100644 19886c5bf31943776128441a9164a7cadbc59ed98906b00271195c982271620a frontend/src/pages/account/ui/components/PositionCard.stories.tsx
file 100644 57c1259d226a8cffd751cb12014f5bd79cdf7b45216c14ec246457f14ce31eb8 frontend/src/pages/account/ui/components/PositionCard.view.tsx
file 100644 9a608c85f49c8a4231790308c79201efb526aca2251348532032cbf4ba1f70fe frontend/src/pages/account/ui/components/PositionLogo.view.tsx
file 100644 b947e40bb3d3306dc010446f342c75d5c7c2a55e081f65a23b4ad8b23fdff50e frontend/src/pages/account/ui/components/PositionSection.view.tsx
file 100644 c81327808061e9d6180a682a52a132b3a9c944ea6c7ea7175d2e9e2c556bb6b1 frontend/src/pages/account/ui/components/RecentEvents.stories.tsx
file 100644 42f0b5bd6bb5d50ba156a8380c0b821e96c1cf57316d62e261eb6d2b0e793226 frontend/src/pages/account/ui/components/RecentEvents.view.tsx
file 100644 11e3b1de6a35f484d2f17cf6c079e160c61dfaf6329355b21671a6cd0b937383 frontend/src/pages/account/ui/components/RecentOperations.view.tsx
file 100644 d894b7b5d573339279d5b76fd67ee60fa4b3940883227f32e56e82f1b2a713b1 frontend/src/pages/accounts/index.ts
file 100644 f797ed0eb20092db09af055477d0d007818cf11e4266d429273222bd601830e5 frontend/src/pages/accounts/ui/AccountsPage.test.tsx
file 100644 b40e07504262fa525acacddb49324a83639aa0cf2a39f7ed131f7d711c9014ce frontend/src/pages/accounts/ui/AccountsPage.tsx
file 100644 8e704043780f80d6a5ee7eb3d62ab7cb7b9b2dd47f1bcc9949f60a576bb4a7fb frontend/src/pages/accounts/ui/AccountsPage.vm.ts
file 100644 14c04cf71bff77de0bb1e11a938d1b1a1dafe362a2e1f8ce2f075c7952349e68 frontend/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx
file 100644 0c4b155c4955ce4a397c5b79f8fd2408cb5c6ba02fe1064f7c919197ccb32f35 frontend/src/pages/accounts/ui/components/AccountsPageError.view.tsx
file 100644 7912cf312e941676265f9fac7319ae0affcd8aaac14690186191f82e6680e015 frontend/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx
file 100644 2386da6dc9fbe23b9a4379ad4ff8fd0cf539e5ea3b46b5b5c45c5cb74360bea2 frontend/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx
file 100644 74899aba17ff330f749232f50ba0e251549bd7e8546090898fd8d829f19ed7bb frontend/src/pages/accounts/ui/components/AccountsRefreshState.view.tsx
file 100644 eecb1f89e2f1f7b6749585a6cd3adff86513a8bf5b2b53f3ead46de7ad3fc45b frontend/src/pages/login/index.ts
file 100644 da0abc69206621cddd71c4755edf7354a8f740df18807062e66e1e96e47d878e frontend/src/pages/login/ui/LoginPage.tsx
file 100644 6962cb472751d79b33ef31a46d18e057842320a062f39dad64a08cf38df338c3 frontend/src/pages/not-found/index.ts
file 100644 0621bd989fd15bb787e5bb8c07d95aab93a595a7aff2307b24bbe9c3cf82060c frontend/src/pages/not-found/ui/NotFoundPage.tsx
file 100644 97c0a1f58ca3592e9c4b0440b8ba6fe0c710eb53abd8b425cfdb806cfca14feb frontend/src/pages/register/index.ts
file 100644 94c558dc9d772afee6c9070ab74506a916308b4e333b0bc307285e295bf1cbd1 frontend/src/pages/register/ui/RegisterPage.tsx
file 100644 6081571ece8bfbb6fe1a497f8e8f9517c5a208f2cc3872dbbb4a8013fef78d1d frontend/src/pages/settings/index.ts
file 100644 943f183ea10396202a624eee5eac68d505dc258d0303c2cdcc18261bbd406d01 frontend/src/pages/settings/ui/SettingsPage.tsx
file 100644 059834d468b56472f3cb9a84e3c6ee53da3fd7d14d286ab03cf12ef66a159fb0 frontend/src/shared/api/authenticated-query.test.tsx
file 100644 2054ffb0279b1e72cfec24973fd212e25eef3d8e75a80f2cebaa8f496b0d02a2 frontend/src/shared/api/authenticated-query.ts
file 100644 21f632568273b6b192e1b9ebd4f63b7aeba65120b4efe75698e2fc0ec3c3988e frontend/src/shared/api/client.test.ts
file 100644 2454f54bf2d430feb765456aaf5ffb468fc068fb75bc13c6c257eb525683ba98 frontend/src/shared/api/client.ts
file 100644 c4aae54b591122701bc751f30feb57212f1f2df44adbcbfd77100afcef23a57c frontend/src/shared/api/generated/schema.d.ts
file 100644 c9b8bc978df49c35aa01a5997e2d79bcaa89f5ab363005e2019933a34ed88f53 frontend/src/shared/api/index.ts
file 100644 b33daae32b1a4b78b9f1c0535ac9f4bc91b277de718eca78385691b426e7e157 frontend/src/shared/api/problem-details.ts
file 100644 281f9aee4f254e85252f7688de7de1d0414ea2ee547a82d28fa4a1b7482b477f frontend/src/shared/api/spec/openapi.json
file 100644 c7b5bbc71bdbecc74dc8d71694fa766fafdd81effb5a4fa1206ef6e400cbf177 frontend/src/shared/i18n/i18n.ts
file 100644 bb3f6e68be414f50b1804a3a64e063ef7ad54f6d0fc2f0b330cd0eeaf25a0e02 frontend/src/shared/i18n/index.ts
file 100644 fa09ce4bde2469b7ead9f4655e78ba11910f506a25394f27e121a7a3503e14e7 frontend/src/shared/i18n/locales/ru.ts
file 100644 2ca092b2dcd83bca8c55d34c08c12c065721b7bd7382a8c87ff9fd9cac35a234 frontend/src/shared/i18n/zod.ts
file 100644 be879cdc1edc49f9356ac4358b5c4ed3c64f148a7d8c62049e477a36e81b289f frontend/src/shared/lib/display.test.ts
file 100644 c23021a1b968342cca8c3b163896991b65ae6d0a7c85469342d8a9ae2ede820b frontend/src/shared/lib/display.ts
file 100644 58a97412329011763f000f3690486e331b8060cad0b7687df8f35f5763019c7f frontend/src/shared/lib/index.ts
file 100644 e8609df757214d122d06d170d764745a02ba92fa2d64410d111176d88e4695ca frontend/src/shared/theme/Foundations.stories.tsx
file 100644 49f7368720c929acf09541dee9405d90f4ba41caf15aaee078d1527305d722a4 frontend/src/shared/theme/index.ts
file 100644 540661168457fdcf2fceaf201f741415cdf0b0d49b5a46de9c58719a2f2a5491 frontend/src/shared/theme/styles.css
file 100644 1ea0ca1aa2ed78a23a51cb22971b469672f3f560027824776e8110a4229703ce frontend/src/shared/theme/theme.test.ts
file 100644 5cce79e8fa932ab1f7130ff318a34515fb11c28e7c3c998aca4f603741e8bfdf frontend/src/shared/theme/theme.ts
file 100644 aa8648a568a935f59bd06e1c1e2aa7bc1a22d9d58db4edbbf89728cc55065f18 frontend/src/shared/theme/tokens.json
missing - - frontend/src/shared/theme/tokens.schema.ts
missing - - frontend/src/shared/theme/tokens.test.ts
file 100644 0ceb0e7f9658577e39056d8c69e235dcf5e84004d9208a06f8117d471e26ea04 frontend/src/shared/ui/AppShell.module.css
file 100644 68be717ac98fc04a3c9407c9eb3ce2cf8f62c975260d61d73e02fcbda1efbd55 frontend/src/shared/ui/AppShell.tsx
file 100644 cb97cbcf25dcd624bcd714d76b43f5972729a1426ee5cbe16ac32a761313390d frontend/src/shared/ui/Button.stories.tsx
file 100644 55a3a27849c7f0856c49179ef5542e842064dc30a4753180e91aa83ab8fd8214 frontend/src/shared/ui/LoadingSkeleton.stories.tsx
file 100644 2d1c5012c2fb93c83c5e7b8921485d392dbc5acbf0896703b32821329f9cbaed frontend/src/shared/ui/LoadingSkeleton.tsx
file 100644 f77300fa254afadeb65895adec96148f9e5b0943e2ed4ea81de258cf99b04048 frontend/src/shared/ui/StateView.stories.tsx
file 100644 de9d44409a715ce9c103e870edaf5b323127053844dcd8bef49c7854c6ccc0c2 frontend/src/shared/ui/StateView.test.tsx
file 100644 7414fa3de07b487c2fbcbf56f67925b418e64b586f9175add1576053fecfd0aa frontend/src/shared/ui/StateView.tsx
file 100644 3322a7129d6fca07f8d3b459d35508d8d7d1cbab22e8a2d49280c75c1813ac3f frontend/src/shared/ui/index.ts
file 100644 5440d1cee5598f9bdcc78ac203af6426b52ce8932bc6bd99366416a4de14a33d frontend/src/test/handlers.ts
file 100644 dd06c186d0d0a5ca218a296edd1f01a58c50f1433a16539c24692aa48eb15d20 frontend/src/test/query.tsx
file 100644 85c107a2f5429cd35c3a0b7ea7c9678934cf63038a66f42a0804d2e3465086cb frontend/src/test/server.ts
file 100644 6eb1bb94d573810cda4ea65da15957907d9c05d32880cb7217330e30a1b73c66 frontend/src/test/setup.ts
file 100644 6c887d8e2a3faf286443ec37d2cf6cef3582beb03339040ceff0412505e70ef6 frontend/src/test/storybook.test.tsx
file 100644 301c6165f2e9297f499870b4c6cf55e4f48aa15bf03a8fc98ea658c6eaa6c21d frontend/tsconfig.app.json
file 100644 975b855a3e1084cbd91c17718bf2099a00afbdad44dcc19b0b1d682f8cea5a10 frontend/tsconfig.json
file 100644 6cab0685be664373524de5ce6b2244b7d6cfdde28902ad3e3aa357f978b72299 frontend/tsconfig.node.json
file 100644 5c781c6de11dd929a9142b07467b51ea39f4886546804595d4075f430b59468a frontend/vite.config.ts
file 100644 7de4e28baa97782333946aa2f0a9ea7ddd4434498089079aad3fa79dbe615b03 frontend/vitest.config.ts
file 100644 39eb6f04b3a2e630f40985e5e4a64663bcf91b9422433dbcf093b6b3df94db81 specs/AGENTS.md
file 100644 8f71edf411818fb21b413c2172c63cc785cb6cd3716d7eaab9660daa9243c982 specs/openspec/changes/simplify-local-design-tokens/.openspec.yaml
file 100644 f9688ad20a5a8b85f5ce6b1df49e807efc8a554e8aae35ab67549b35699a05fe specs/openspec/changes/simplify-local-design-tokens/design.md
file 100644 deeccba5dbc3187111ab80c806f94580c4bc9c4b2f80d64c06d14c621756824b specs/openspec/changes/simplify-local-design-tokens/proposal.md
file 100644 911b9a4e900f37868a9959ff65f3f38ffa81473df974bba36d3313035829c8f6 specs/openspec/changes/simplify-local-design-tokens/specs/frontend-design-system/spec.md
file 100644 5aa52960b34d3ff8058d9da396ddc7c78f17f72b8692d795e14147add89d9819 specs/openspec/changes/simplify-local-design-tokens/tasks.md
file 100644 c5a7c8728824abcf525c4f26d3e7089c54396513d80d2ba4edba15c7a9ba5771 specs/openspec/config.yaml
file 100644 d4775e71e157bd144256ab62e40294a7f3d76173456c928b223a6b631d8ee629 specs/openspec/schemas/wealth-ledger-sdd/schema.yaml
file 100644 8d7749fe5bc32ef4acf70bcc4af22b95c615539fe866bc1de36571f3563b74d4 specs/openspec/schemas/wealth-ledger-sdd/templates/design.md
file 100644 d0aa6958b1e7abc3bae5516d3662e81caf9a46c6b34ee98045fb2b863a710a15 specs/openspec/schemas/wealth-ledger-sdd/templates/proposal.md
file 100644 8c01eb2f297af110cff4a12b998443684645fa54127d898a78b7765985ccb3a3 specs/openspec/schemas/wealth-ledger-sdd/templates/spec.md
file 100644 8722869157106bae35438834d4c05703dc3b3799df7377ec74ee905b8cdf477a specs/openspec/schemas/wealth-ledger-sdd/templates/tasks.md
file 100644 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 specs/openspec/specs/.gitkeep
file 100644 073d4e19163c6344a67c6cac99f11f60378d3ac6f5783c27ec88d2c92a67d713 specs/openspec/specs/agent-sdd-session-workflow/spec.md
file 100644 084ff97d258ab7c1e0eb6af27b36f054a8e16770813a3af5ac823f7385dbe53b specs/openspec/specs/api-contract-generation/spec.md
file 100644 d7ef01f72a89454a47e096b5f8666794d0d00d25f5a954aedb9ea2c3773f4d09 specs/openspec/specs/app-breadcrumb-navigation/spec.md
file 100644 a609567be6e205bd47d520acb67c9c6dbf382df541a91cfb1e52e831a9006b4b specs/openspec/specs/audit-remediation-workflow/spec.md
file 100644 25ea8975444c21aacfd78da0652c75c40906a68ba13b704cbe627ac5c8d9e3d8 specs/openspec/specs/backend-structured-logging/spec.md
file 100644 a5bbdc430022a3261383fb34430a1abe67e3811b40c36356ada24a250323faf8 specs/openspec/specs/brokerage-account-income-analytics/spec.md
file 100644 597db4690932fe06b735d94f690fe97ec95ad8ebfefb242a7e195b3a579f12bf specs/openspec/specs/brokerage-account-ledger/spec.md
file 100644 e04642cf3abdc694a879d036d28484289e31dc284843b47164af498eb5c8db7b specs/openspec/specs/brokerage-account-upcoming-events/spec.md
file 100644 8e7d826685c6e980efcf24356f5ae7800da349775d78be2c6c76183a31eba664 specs/openspec/specs/brokerage-accounts/spec.md
file 100644 ca75600c855a3bdbe46231279ce4bbfaacd9167ced1715c195797933f115b00b specs/openspec/specs/engineering-change-governance/spec.md
file 100644 4a63906e7dd2b83cc2fc7905444703c6eba0d4a0faa6d206362e70eb6e6798c9 specs/openspec/specs/frontend-data-resilience/spec.md
file 100644 9b761cfd70293f957a213de57d2b8099e225e89c2901b96546796b9be47c993c specs/openspec/specs/frontend-design-system/spec.md
file 100644 820f2cfdb583284cc6dad23d782ca8c720baa523800042a92b4daac6760e0f0f specs/openspec/specs/frontend-loading-presentation/spec.md
file 100644 07ce2f3cf0b128d9cb41b387c18977ca7e965b0cbbef9ea53b9354bc0337d35e specs/openspec/specs/frontend-quality-guardrails/spec.md
file 100644 e87e41adeaa0fa3859e29735009da5e8a6c13a3da26aab74c8fa3be2a33892e2 specs/openspec/specs/frontend-query-coherence/spec.md
file 100644 39f5543e5356755bc3512a99da866944eeb11def73c1cf05789e8b902aa6096f specs/openspec/specs/frontend-ui-catalog/spec.md
file 100644 3b249d74194004fde105ff6e80669b1f0df2365dda4f0b17b50ed2e4ec1f0297 specs/openspec/specs/frontend-ui-resilience/spec.md
file 100644 3a335f4d109e7944d6fd0ddb05a84e8690547bdec151aeabcd7ac736a2a0f06c specs/openspec/specs/multi-repository-delivery/spec.md
file 100644 de7a7853fd159c6a57487a9ea4e16f1f3dba8d7632d12daab0bdf8116c498342 specs/openspec/specs/openspec-authority-and-lifecycle/spec.md
file 100644 89bb963fba1f0b98d6be0f06b13f25d0cb138f8631e5e050a5ecf5ca749bdfd6 specs/openspec/specs/portfolio-view/spec.md
file 100644 8d370df3819339d9207220afbb953ca58a4ff5b0fe80c069a0348bf024ffe12e specs/openspec/specs/project-documentation/spec.md
file 100644 3694bb6bc69ec1926a545aa70f799a2581589ddce4519953c09ebe9fd71544d7 specs/openspec/specs/project-foundation/spec.md
file 100644 225cceba45eef078a39f93264575c633570ab3004fee989d81f02b0852693b2c specs/openspec/specs/tbank-connection/spec.md
file 100644 f4bf3fa2706da03a348fbde7219b8246a06f52405b91fd977bdb065b4d81ecf9 specs/openspec/specs/user-authentication/spec.md
file 100644 cbca6e5a6ac49a5cd70fcf560f2e52c2e5bd0f7df1f2ce5a5c75c42dfb9551b1 specs/openspec/specs/workspace-artifact-hygiene/spec.md
```
@@ -0,0 +1,75 @@
## Context
JSON v1 хранится в shared/theme и импортируется при сборке. Zod-схема и
большая часть token tests обслуживают неподдерживаемый сценарий произвольного
входного документа. App/Storybook уже используют общий public API.
## Goals / Non-Goals
**Goals:** прямое потребление локального JSON, сохранение адаптации и визуальных
значений, небольшой тест собственных преобразований.
**Non-Goals:** замена runtime-схемы новой системой типов или генератором,
изменение JSON/компонентов/провайдеров, удаление Zod из бизнес-форм,
внешняя загрузка токенов, редизайн и новые зависимости.
## Decisions
- Удалить tokens.schema.ts и связанные negative/document-shape tests.
theme.ts напрямую импортирует tokens.json, сохраняет public exports и
Mantine rem/rgba/getThemeColor adaptation.
- Использовать вывод типов JSON и существующие типы/helpers Mantine там,
где JSON расширяет литералы; не создавать параллельное полное описание
token contract. Type assertions при необходимости ограничены границей
адаптера и не считаются validation.
- Два кратких theme.test.ts сценария защищают собственные преобразования:
scale-aware размеры/тени и unscaled breakpoints; semantic color references.
Это оставшаяся полезная часть текущих тестов, без тестирования схемы.
- Формат JSON и правила редактирования сохраняются в as-built docs. Корректность
локальных изменений проверяется обычными repository gates, review и UI proof;
полный автоматический отказ для произвольного invalid JSON больше не обещается.
## UI/UX Applicability and Package
**Applicability and scope:** N/A: значения, стили, экраны, states, interactions
и responsive behavior не меняются; удаляется только runtime-проверка конфигурации.
Новый визуальный пакет не требуется.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | affected | Прямой JSON adapter, компактный theme test и ownership docs |
| backend | N/A | API/auth/storage и денежная логика не затронуты |
| specs | affected | Уточнён контракт доверенного локального источника |
| docs | affected | As-built описание прямого адаптера и границ проверки |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | N/A | Ни одна сторона API contract не меняется |
## Risks / Trade-offs
Произвольное некорректное значение не обязано вызывать раннюю ошибку загрузки.
Это согласованное упрощение для конфигурации репозитория, а не снятие проверки
на границе внешних данных: такого входа здесь нет. Если появится внешняя загрузка,
её trust boundary и validation должны проектироваться отдельно.
## Migration Plan
Сохранить baseline преобразований; удалить схему и её тесты, подключить JSON
напрямую; актуализировать документацию и проверить неизменность темы.
Прямое разрешение владельца распространяется на planning и delivery без
session records и повторных промежуточных approvals.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | required |
| Trigger | Адаптер меняет представление либо потребителю необходим прежний runtime-отказ |
| Actions | Вернуть delivery diff и согласовать обратную spec delta; historical archives не менять |
| Data safety | JSON v1 и пользовательские данные остаются прежними |
| Verification | Прежние преобразования и обе сборки проходят проверки |
@@ -0,0 +1,31 @@
## Why
Токены поставляются вместе с приложением и редактируются в репозитории.
Полная проверка произвольного входного документа добавила сложность, хотя
загрузка сторонних токенов не предусмотрена. Владелец согласовал её удаление.
## What Changes
Упростить потребление локальных токенов, сохранив переносимый источник,
единую тему и прежнее представление приложения и каталога. Убрать гарантию
runtime-отказа на произвольные некорректные токены; оставить проверки собственных
преобразований темы. Формат и значения токенов не меняются.
Вне scope: внешняя загрузка токенов, редизайн, новые компоненты или зависимости,
продуктовые сценарии и изменение исторических архивов.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `frontend-design-system`: доверенный локальный источник вместо обязательной
runtime-валидации произвольных документов; остальные инварианты сохраняются.
## Impact
Затронуты общий frontend-фундамент, актуальная спецификация и документация.
Пользовательское поведение, API и данные остаются прежними.
@@ -0,0 +1,57 @@
## MODIFIED Requirements
### Requirement: Приложение и каталог используют единый визуальный фундамент
Приложение и каталог SHALL использовать один источник темы и токенов: цвета
с семантическими ролями, типографику, интервалы, радиусы, тени и применимые
адаптивные параметры. Общий фундамент MUST быть независим от бизнес-сущностей
и уровня приложения; подключение провайдеров принадлежит приложению и runtime
каталога. Объявленные общие токены SHALL иметь единый переносимый машиночитаемый
источник значений, единиц, семантических ссылок и описаний, доступный без
приложения или UI-библиотеки. Адаптер приложения SHALL потреблять этот источник
без ручного дублирования значений. Перенос владения опубликованными шкалами
из UI-библиотеки в токены MUST сохранять их эффективные значения; не включённые
в контракт библиотечные defaults MAY оставаться у библиотеки. Выделение фундамента MUST сохранять текущий
внешний вид, светлую схему и поведение приложения без массовой замены локальных
значений оформления. Источник SHALL поставляться вместе с приложением как
доверенная конфигурация; загрузка внешних или пользовательских токенов
и отдельная runtime-валидация произвольных документов не поддерживаются.
#### Scenario: Компонент отображается в приложении и каталоге
- **WHEN** один компонент с одинаковыми props отображается в обоих runtime
- **THEN** он получает одинаковые тему, токены и общие визуальные правила
#### Scenario: Участник выбирает токен
- **WHEN** участник изучает фундамент дизайн-системы
- **THEN** доступны используемые шкалы, их значения, семантические роли и правила
применения без необходимости обращаться к данным приложения
#### Scenario: Владение темой перенесено
- **WHEN** приложение использует выделенный общий фундамент
- **THEN** исходные эффективные значения и внешний вид остаются прежними,
включая встроенные шкалы, глобальные стили и локальное оформление
#### Scenario: Токены использует другая система
- **WHEN** потребитель читает исходный файл обычным парсером данных
- **THEN** объявленные цвета, роли, типографика, интервалы, радиусы, тени и
адаптивные пороги доступны с определёнными единицами и ссылками без React,
Mantine, CSS runtime или запуска приложения; интеграция конкретного инструмента
не заявляется автоматически
#### Scenario: Изменён исходный токен
- **WHEN** валидное значение или семантическая ссылка изменены в едином источнике
- **THEN** приложение и каталог получают изменение через один адаптер, без
повторного редактирования визуального значения в коде темы
#### Scenario: Исходные токены некорректны
- **WHEN** разработчик вносит некорректное значение в локальный источник
- **THEN** отдельный runtime-валидатор не применяется; корректность изменения
проверяется при review и проверке представления, автоматическое отклонение
любого некорректного документа не гарантируется
@@ -0,0 +1,11 @@
## 1. Упрощение локальных токенов
- [x] 1.1 [frontend] Удалить runtime-схему и её тесты, подключить JSON напрямую, оставить два теста адаптера и актуализировать ownership docs.
- **Covers:** `frontend-design-system` — `Приложение и каталог используют единый визуальный фундамент`
- **Verification:** npm ci и npm run verify под .nvmrc; baseline/after theme и browser smoke Design System/Product/login; JSON values и provider contract неизменны.
- [x] 1.2 [specs] Оформить согласованный контракт доверенного источника и planning без изменения historical archives/state.
- **Covers:** `frontend-design-system` — `Приложение и каталог используют единый визуальный фундамент`
- **Verification:** openspec schema validate wealth-ledger-sdd --verbose; openspec validate simplify-local-design-tokens --type change --strict --store wealth-ledger-specs; openspec validate --specs --strict --store wealth-ledger-specs; semantic sync review.
- [x] 1.3 [docs] Описать прямое потребление локального JSON и фактические границы проверки.
- **Covers:** `frontend-design-system` — `Приложение и каталог используют единый визуальный фундамент`
- **Verification:** npm ci и npm run verify под docs .nvmrc; affected local links и соответствие реализации.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-09-09
@@ -0,0 +1,45 @@
# clarify-agent-context-routing — закрытие
**Дата:** 2026-09-09. **Статус:** архивирован; исходный план заменён последующим
решением пользователя. Итоговая задача реализована для frontend и backend.
## Основание
После первоначального переноса frontend conventions пользователь расширил
задачу: применить рекомендации исследований к обоим code repositories и убрать
обязательный OpenSpec/SDD. Решение закреплено в
[ADR 012](../../../../../docs/adr/012-task-driven-development-with-scoped-context.md).
Затем пользователь явно запросил завершение, архивацию и commits.
Proposal, Design, Tasks и delta specs сохранены как история первоначального
scope. Отметки Tasks относятся к прежнему этапу; они не означают соответствие
итоговой структуры старому плану или проведение независимой SDD Validation.
Архивация выполняется с `--skip-specs`: delta требует обязательных SDD-фаз
и исключает backend в planning scope, поэтому её перенос в main specs
не соответствует последующему решению пользователя.
## Поставленный результат
- Короткие root/local AGENTS и загрузка conventions по задаче, пути и границе.
- [Frontend router](../../../../../frontend/.ai/CONVENTIONS.md), SOURCES и четыре тематических руководства.
- [Backend router](../../../../../backend/.ai/CONVENTIONS.md), SOURCES и шесть тематических руководств.
- Общие engineering rules, HTTP contract workflow и docs/specs indexes.
- [18 routing cases](../../../../../docs/development/agent-context-evals.md) с безопасными исключениями и неприменимыми контрпримерами.
Код приложения, dependencies, runtime configuration и gates не менялись.
Основные specs и существовавшие архивы сохранены. OpenSpec остаётся optional;
эта архивация не возвращает обязательный lifecycle.
## Проверка
Проверки выполнены под `.nvmrc` соответствующих repositories:
- `frontend`: `npm run verify` — exit 0; 26 test files, 147 tests, сборки приложения и Storybook.
- `backend`: `npm run verify` — exit 0; 25 suites, 107 tests, Prisma generation и Nest build. Для Prisma cache потребовался повторный запуск с разрешённым доступом вне sandbox.
- `docs`: `npm run verify` — exit 0; Markdown lint и `git diff --check`.
- Root/local AGENTS, оба `.ai` дерева и specs index: Markdown lint — 19 файлов без ошибок; scoped navigation — 60 файлов, 413 links/anchors без ошибок.
- Все 18 routing cases проверены вручную. Это не эксперимент по качеству агента; результаты не доказывают first-pass acceptance или экономию токенов.
Runtime HTTP contract не менялся; отдельные contract/e2e проверки не запускались.
Известное ограничение общего backend e2e и непроверенная детализация validation
errors описаны в действующих документах; их исправление не входило в эту задачу.
@@ -0,0 +1,300 @@
## Context
Шесть frontend-local документов сейчас находятся в `frontend/docs/`.
Их README перечисляет темы, а local AGENTS одновременно служит входом и кратким
пересказом отдельных правил. Общая policy, context discipline, lifecycle,
architecture и as-built сведения уже имеют владельцев в отдельном `docs`.
Подход сохраняет это разделение и использует утверждённую Delta Spec
`specs/openspec-authority-and-lifecycle/spec.md` данного change.
Адресная проверка текущих источников уточнила основания решений:
- `frontend/biome.json` задаёт ошибки для declarations named components,
default exports и порядка exports с локальными исключениями. Он не задаёт
общего запрета непереведённых JSX-строк; TypeScript такого запрета не добавляет.
- `@testing-library/user-event` не объявлен напрямую в `frontend/package.json`,
но присутствует в lockfile через Storybook. Интерактивные тесты используют
`fireEvent` из объявленного `@testing-library/react`.
- Project-local schema `wealth-ledger-sdd` имеет version 5. Отслеживаемый Git
файл `specs/openspec/.cli-version` существует и содержит `1.10.0`.
ADR 010 описывает прежние schema v3, custom verifiers и dispatcher;
текущие lifecycle и entrypoints направляют к native OpenSpec и repo gates.
- Storybook уже поставлен. Workflow summary содержит прежнее число групп,
тогда как каталог описывает актуальную структуру у собственного владельца.
Это основания для уточнения документации, а не для изменения tooling или
исправления других main-spec requirements. Planning остаётся только в store
`wealth-ledger-specs`; исторические источники не становятся объектом миграции.
## Goals / Non-Goals
**Goals:** сохранить локальные правила при смене пути, определить единственного
владельца каждого решения о контексте и дать исполнителю точный способ отличать
обязательное чтение и review от автоматического enforcement и guidance.
Design фиксирует соответствие сохраняемого содержания конечным владельцам;
отдельный постоянный реестр для этого не нужен.
**Non-Goals:** изменение application runtime, API, auth, данных, dependencies,
schema graph или canonical gates; новый context framework, instruction layer,
checker, dispatcher, scoring либо token limit. Bundle-budget drift,
backend e2e, переносимость Completion Evidence и массовое переустройство
conventions остаются вне этого change.
## Decisions
### 1. Существующие источники сохраняют ownership
`frontend/.ai/` полностью заменяет `frontend/docs/` как место локальных правил.
В нём непосредственно лежат `README.md`, `CONVENTIONS.md`, `STRUCTURE.md`,
`API-LAYER.md`, `UI-PATTERN.md` и `TESTING.md`. Это relocation существующего
набора, без вложенного дерева, metadata, нового AGENTS или второго index.
Старые поддерживаемые копии и redirect stubs не остаются.
Frontend AGENTS направляет к `.ai/README.md` для выбора применимых документов,
сохраняет local gate и краткие boundaries. README приложения предоставляет
тот же вход и полезные прямые ссылки. Краткие pointers в AGENTS не заменяют
текст владельца и не требуют автоматически читать весь набор conventions.
Общая authority остаётся в `docs/development`: context discipline владеет
выбором и повторным определением inputs, lifecycle — входом по фазе и native
процедурой, governance — approvals, ролями и обязательностью проверок.
Architecture, ADR и as-built сведения остаются в своих текущих docs-разделах.
Frontend router ссылается на эти источники вместо копирования policy.
Альтернатива с двумя поддерживаемыми каталогами оставляет неоднозначный owner.
Новый registry или metadata layer создаёт дополнительную синхронизацию без
нового поведения; шесть существующих документов уже соответствуют границам.
### 2. Один локальный router выбирает объединение применимых тем
Таблица маршрутов принадлежит `frontend/.ai/README.md`. Она использует
содержание предстоящего решения, а не только расширение файла или название
задачи. Одновременно действующие triggers объединяются:
| Содержание изменения | Локальный документ в `.ai/` |
| --- | --- |
| Создание/перенос module или slice, cross-slice imports и public API boundaries | `STRUCTURE.md` |
| React declarations, props, exports, продуктовые строки и Zod messages | `CONVENTIONS.md` |
| Container/VM/view, формы, query states, skeleton, retry/recovery | `UI-PATTERN.md` |
| HTTP client, generated types, query/mutation, Bearer и refresh | `API-LAYER.md` |
| Изменение самих тестов либо поведения с testing risk trigger | `TESTING.md` |
Например, UI props и presentation требуют conventions и UI pattern; auth
refresh добавляет API и testing по security/concurrency risk. Наличие query
state во view само по себе не требует API rules, пока не затронуты получение
данных, query/mutation или transport boundary. Router не дублирует список
risk triggers: их полная policy остаётся в governance с указателем из TESTING.
### 3. Повторный routing расширяет существующую context discipline
В `docs/development/agent-token-efficiency.md` уточняется существующий раздел
Routing context. Фаза и affected areas определяются до supporting reads;
применимость inputs сверяется до первого зависимого решения, при новой области
или границе и перед handoff. Первая сверка работает и при отсутствии diff.
Перед handoff основанием служат фактические staged, unstaged и новые файлы
вместе с содержанием работы. Используется существующая inventory procedure
этого же документа: учитывается working content tracked и nonignored untracked
files, relevant ignored и workspace-owned inputs перечисляются явно.
Чужой concurrent diff учитывается при разграничении ownership, но не даёт
полномочий присвоить его текущему change. Второй manifest не вводится.
Новая граница в пределах approved scope означает загрузку её правил до
зависимой работы. Необходимость изменить intent, observable behavior,
architecture или Tasks означает возврат к соответствующей самой ранней
planning boundary. Само уточнение чтения не требует повторного approval;
расширение разрешённой работы следует существующей cascade policy.
В existing lifecycle достаточно коротких указателей на эту сверку в native
planning, Implementation/Validation и перед передачей результата. Отдельная
таблица фаз и контекста там не создаётся: владельцем mapping остаётся context
authority, а конкретные dependencies/contextFiles возвращает native instruction
разрешённого artifact или Apply.
При recovery восстанавливаются canonical artifacts и их approval bindings,
все обязательные native inputs, применимые risk inputs и согласованный Design
package текущей фазы. В том же контексте неизменившиеся прочитанные inputs
переиспользуются по действующей policy. Missing input, неоднозначная версия
или digest mismatch блокируют зависимое решение. Краткий handoff, внешний
каталог и число документов не заменяют обязательные источники.
### 4. Перенос сохраняет требования и действующие исключения
Следующая таблица относится только к этому Design: это соответствие исходного
содержания конечному owner для semantic review переноса. Она не становится
постоянным convention registry. Правила по возможности остаются в одноимённом
перенесённом документе; полезные краткие pointers между ними сохраняются.
| Исходное содержание | Конечный владелец | Что должно сохраниться |
| --- | --- | --- |
| `frontend/docs/README.md`: назначение набора, темы, общая policy и источник версий/scripts | `frontend/.ai/README.md` | Компактный router, ссылки на общие authorities; фактические dependencies и scripts определяет конфигурация проекта. |
| `frontend/docs/STRUCTURE.md` и FSD boundary из AGENTS | `frontend/.ai/STRUCTURE.md` | Локальные слои `app → pages → features → entities → shared`, без `widgets`; slice public API через `index.ts`, alias `@/` и запрет обхода public API. Владение theme/tokens, общий public API для app и Storybook, границы shared/ui и ссылка на дизайн-систему. |
| `frontend/docs/CONVENTIONS.md`: общие Must | `frontend/.ai/CONVENTIONS.md` | Biome style, type-only imports, правила enum/namespace, exact dependency versions, colocated tests, Conventional Commits и действующая pre-commit процедура. Public API и gate pointers сохраняют связь с владельцами. |
| `frontend/docs/CONVENTIONS.md`: i18n и Zod | `frontend/.ai/CONVENTIONS.md` | Продуктовые строки в locale через `useTranslation()`; Zod messages через `validationMessage(key)`. Обязательность сохраняется при semantic review без обещания общего автоматического запрета JSX-строк. |
| `frontend/docs/CONVENTIONS.md`: React component style | `frontend/.ai/CONVENTIONS.md` | Const-valued named components, export rules, `FC`, named props, `PropsWithChildren`, отсутствие inline props typing и destructuring parameters, читаемый нижний export block и прямые barrel re-exports. Storybook CSF default meta/direct story exports и конфигурационные default-export exceptions сохраняются в фактическом scope Biome; synthetic story data не подменяют i18n продукта. |
| File roles в `CONVENTIONS.md` и VM/view rules в `UI-PATTERN.md` | `frontend/.ai/CONVENTIONS.md` и `frontend/.ai/UI-PATTERN.md` соответственно | Container вызывает page VM; VM владеет data/auth/route inputs, state selection, domain grouping и derived presentation data; view получает props. Сохраняются запрет data/auth/router/navigation/VM hooks во view, допустимые translation/form-context/UI-only hooks и formatter одного значения. Разбиение по semantic responsibility, flat views и исключение для маленького tightly-coupled renderer не заменяются line-count rule. |
| `frontend/docs/API-LAYER.md` и API boundary из AGENTS | `frontend/.ai/API-LAYER.md` | Backend OpenAPI как authority; committed snapshot и generated types, offline generation/drift check, запрет ручного редактирования generated и дублирования transport DTO. Только typed shared clients, отсутствие прямых fetch в features/pages/entities и прямых T-Bank calls, slice ownership cache keys/policies. Bearer принадлежит shared authenticated fetch; auth endpoints исключены; single-flight refresh и один retry `UNAUTHORIZED`; query/mutation не задают Authorization. |
| `frontend/docs/UI-PATTERN.md`: формы и recovery | `frontend/.ai/UI-PATTERN.md` | React Hook Form, Zod resolver и FormProvider; form state в container/VM, context fields во view; блокирование действий при submit; field errors и сетевой Alert с сохранением ввода. Known API errors/retry локальны, непойманные route errors принадлежат route-keyed ErrorBoundary. |
| `frontend/docs/UI-PATTERN.md`: query states и skeleton | `frontend/.ai/UI-PATTERN.md` | Loading/error/empty/loaded без успешных данных; Client Cached Snapshot при refetch и его ошибке с progress, предупреждением и retry, отдельно от server stale/fallback. Page/section ownership structural skeleton, доступная busy-область, декоративные shapes без данных, интерактивности и фокуса; сохранение shell, neutral auth skeleton до ready. Mutation loading и cached-snapshot progress остаются контекстными indicators. |
| `frontend/docs/TESTING.md` | `frontend/.ai/TESTING.md` | Risk-based selection и ссылка на governance, observable behavior/AAA, validation без запроса при невалидной форме, route access/redirect и recovery cases, finance states и route-boundary reset. MSW только из Vitest setup, без browser worker/mock development mode; transport headers/retry через MSW. Один coverage-backed Vitest в gate, короткий локальный test run, coverage как anti-regression constraint без тестов ради процента. |
| Canonical gates и lifecycle safeguards в entrypoints | Local AGENTS affected repos и текущие `docs/development` authorities | Выбор Node по `.nvmrc`, repo-owned gates, fixed store, отдельные approvals, fingerprints, независимая Validation и Finalization. Их определения не копируются в новый локальный workflow. |
Неточность testing helper исправляется внутри TESTING: описывается используемый
`fireEvent` из Testing Library с ожиданием наблюдаемого async outcome.
Транзитивный `user-event` не объявляется отсутствующим на диске или напрямую
принятым проектом. Замена текста не меняет тестовую инфраструктуру и не требует
переписывания существующих тестов.
### 5. Enforcement описывается у владельца правила
В AGENTS и conventions различаются три категории: автоматическое блокирование
существующей проверкой, обязательная процедура/semantic review и guidance.
Автоматический статус указывается только для фактически проверяемого scope;
исключения описываются рядом. Component declarations и export rules сохраняют
Biome enforcement, а `FC`/props conventions и полнота i18n остаются обязательными
для review. Успех type/style checks не доказывает локализацию всех строк.
Раздел Guardrails общей governance уточняется в той же классификации:
определение Enforceable Engineering Invariant сохраняется для детерминированных
проверок, но отсутствие checker не переводит явно обязательную semantic policy
в guidance. Необязательные эстетические предпочтения остаются guidance.
Так общий owner и frontend instructions не дают противоположных указаний.
Новый автоматический запрет здесь не создаётся. Existing gates сохраняют свои
inputs и ответственность; structural validation не получает полномочий
подтверждать semantic compliance, approval или независимый PASS.
### 6. Текущая навигация обновляется без переписывания истории
Действующие входы обнаруживаются по старым путям и разрешаются относительно
файла-источника, включая локальные anchors. Текущий адресный inventory содержит
frontend AGENTS/README, UI-каталог и live ссылки на conventions/UI pattern в
remediation registry. Для registry меняются только эти адреса: Audit IDs,
dispositions, статусы, даты, формулировки findings и evidence links сохраняются.
В текущих main specs, schema и optional `.opencode/commands` ссылок на старые
frontend paths не найдено. Optional entrypoints уже направляют к lifecycle и
context authority, поэтому отдельная их редакция не нужна. Root AGENTS также
сохраняет действующий routing. При последующей адресной сверке новая активная
ссылка включается только в пределах разрешённой области; чужой approved
artifact нельзя переписывать для чистого результата поиска.
Historical OpenSpec archives, dated audits, прежнее Completion Evidence и
сохранённые датированные research/browser snapshots не редактируются.
Generated graph/cache outputs не являются поддерживаемыми instructions и не
регенерируются ради смены пути. Совпадение старого пути само по себе не означает
сломанный действующий маршрут. Отказ от redirect stub делает текущие пропуски
видимыми и сохраняет единственного владельца правил.
### 7. ADR и workflow summaries направляют к текущей процедуре
По правилу `docs/adr/README.md` замена решения оформляется новым ADR со ссылкой
на прежний. В `docs/adr` добавляется ADR 011
`011-native-openspec-lifecycle-with-repository-gates.md`, фиксирующий уже
действующий native lifecycle и repo-owned gates. Он содержит контекст, решение
и последствия этой замены, ссылки на governance/lifecycle/context authority
и на ADR 010; новый процесс или самостоятельный порядок команд не вводится.
В ADR 010 сохраняются исходное rationale и рассмотренные альтернативы, а
заменённые части про schema v3, custom verifiers и dispatcher получают явную
историческую квалификацию и ссылку на successor. Сохраняющийся выбор native
OpenSpec и независимых repo gates не объявляется отвергнутым. Index отражает
частичную замену. Такой подход предпочтительнее полного переписывания ADR 010
под сегодняшний текст или примечания без successor, нарушающего ADR policy.
Документация различает текущую schema version и наличие `.cli-version`;
удаление этого файла не утверждается. Его наличие также не выдаётся за
доказательство запуска прежнего verifier. Этот change не меняет version
contract, CLI pin, schema или untouched requirements о tooling.
Числовая структура Storybook принадлежит `docs/development/ui-catalog.md`.
`frontend-workflow.md` ссылается на этот owner без собственного числа групп.
Frontend entrypoints точно описывают уже поставленный каталог и его участие
в существующем gate. Использование каталога разрешено как существующей
инфраструктуры; ограничения на добавление новых инструментов сохраняются.
Reference каталога не заменяет отдельный Design approval и независимую Validation.
### 8. Доказательство опирается на routing scenarios и existing gates
Semantic review сопоставляет перенос с таблицей сохранения и применяет
утверждённые сценарии Delta Specs к получившимся маршрутам. Для UI-only,
API/auth, изменения тестов, структуры и строк проверяется состав применимых
документов; docs-only опечатка служит неприменимым контрпримером к обязательному
чтению всех frontend conventions.
Scope expansion рассматривается отдельно внутри approved границ и за ними;
handoff включает unstaged/new inputs, recovery — весь обязательный пакет,
а недоступный input должен оставлять явный blocker. Это проверка принятия
решений по содержанию, без unit tests, повторяющих Markdown, и без нового
checker. Ссылочная проверка и сохранение исторических bytes дополняют
canonical gates affected областей; они не подменяют независимую Validation.
## UI/UX Applicability and Package
**Applicability and scope:** `N/A`. Change меняет расположение и содержание
инженерных инструкций, не экраны, состояния, interactions или responsive
behavior приложения/каталога. Макеты, PNG/SVG exports и immutable visual reuse
inputs не нужны. Весь Design package — этот Markdown-файл.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Единственный набор conventions непосредственно в `.ai/`, локальный router, точные инструкции и актуальная навигация; runtime, dependencies и gate не меняются. Область проходит свой canonical gate. |
| backend | `N/A` | Не меняются backend behavior, contract, implementation, local instructions или gate. Backend delivery и backend-only проверки не требуются этим change. |
| specs | `affected` | Canonical planning и уточнение capability `openspec-authority-and-lifecycle` по approved Delta Specs; native structural checks. Синхронизация main specs остаётся штатной completion operation, historical archives сохраняются. |
| docs | `affected` | Context discipline и lifecycle pointers, точная классификация обязательности, историческая квалификация ADR 010 с successor, workflow summary и действующие links. Общая as-built authority остаётся на месте; область проходит canonical docs gate и scoped link review. |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `N/A` | Ни backend runtime contract, ни frontend snapshot/generated types не меняются. Сохранение описания typed API и auth ownership не является изменением сторон API contract. |
## Risks / Trade-offs
- Перенос с сокращением может потерять оговорки. Одноимённые владельцы,
ограниченные точечные редакции и таблица сохранения делают semantic diff
проверяемым; массовая консолидация текста не нужна.
- Без redirect старый live link перестанет работать. Scoped review отличает
действующую навигацию от неизменяемых исторических ссылок и проверяет targets
после relocation, в том числе переходы между независимыми repositories.
- Ручной routing может пропустить вторую тему. Объединение triggers и повторные
сверки по фактическому scope снижают риск; автоматический анализ не обещается.
- Классификация без checker может быть воспринята как ослабление. Явная
mandatory-review категория и согласованный текст общей governance сохраняют
обязательность i18n, validation и прочих semantic требований.
- ADR может смешать историческое и действующее решение. Частичная замена,
successor и точные ссылки сохраняют rationale и отделяют текущую процедуру
от старой реализации; untouched spec drift не скрывается редакцией этого Design.
Build-versus-buy trigger отсутствует: общая tooling/infrastructure не вводится
и существенно не заменяется, используются уже принятые Markdown owners,
native OpenSpec и repository gates. Формальная Solution Assessment не нужна.
## Migration Plan
После отдельных planning approvals перенос выполняется как согласованный
documentation batch: сначала сохраняется одноимённый frontend набор и его
содержание, затем переключаются локальные и межрепозиторные входы. Target paths
и активная навигация должны сходиться до передачи реализации в Validation;
промежуточное смешение старых и новых путей не является завершённой поставкой.
В том же delivery приводятся в соответствие общая context discipline,
классификация enforcement и описание действующего workflow/ADR. Это изменение
существующих owners, без дублирования правил в schema или optional adapters.
Исторические artifacts сохраняются; canonical Proposal и Delta Specs этого
change не редактируются ради переноса ссылок, описывающих исходное состояние.
Проверяемая реализация передаётся отдельной Validation с фактическим inventory.
Canonical gates всех affected областей, independent verdict, semantic sync и
Completion Evidence остаются на своих lifecycle boundaries. Ошибка ссылки или
потерянное требование устраняется в реализации; противоречие утверждённому
approach возвращается к соответствующему planning artifact.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `N/A` |
| Reason | Обратимая документационная relocation не меняет auth/security behavior, данные, публичный API contract или труднообратимое решение. При откате связанный набор frontend/doc navigation возвращается согласованно средствами Git; пользовательские данные и runtime migration отсутствуют. |
@@ -0,0 +1,96 @@
## Why
Действующие инструкции не всегда однозначно показывают, какие локальные правила
нужны для конкретного изменения и когда выбор контекста следует пересмотреть.
Отдельные формулировки смешивают автоматическое блокирование с обязательным
review или описывают прежнюю процедуру как актуальную. Это создаёт риск пропуска
требований и неоправданной уверенности в успешных проверках.
Discovery завершена; первый этап согласован как уточнение существующих
источников и перенос frontend-local conventions. Сейчас нужно закрепить
границы этого этапа, сохранив действующие требования и lifecycle safeguards.
## What Changes
- Сохранить один актуальный набор всех шести frontend-local документов в
согласованной `frontend/.ai/` вместо `frontend/docs/`, с компактным входом по
типу изменения и работающей действующей навигацией. Общая engineering policy,
architecture, ADR и as-built документация остаются у общего docs authority.
- Сделать выбор локального контекста однозначным для изменений структуры и
импортов, компонентов и продуктовых строк, представления и восстановления
состояний, взаимодействия с сервером, а также тестов и поведения с testing
risk trigger. Несвязанная задача не требует чтения всех conventions.
- Закрепить в существующей context authority проверку маршрутизации до первого
зависимого решения, при обнаружении новой области или границы и перед handoff
по фактическому набору staged, unstaged и новых файлов. Расширение обнаруженного
scope не разрешает выходить за approved planning boundaries.
- Сохранить обязательное получение всех native contextFiles, применимых risk
inputs и согласованного Design package при работе и recovery независимо от
количества документов. Фаза определяет нужный контекст; новый workflow или
параллельный context map не возникает.
- Различать реально автоматизированные запреты, обязательные процедуры и
semantic review, а также необязательное guidance. Исправление неточного
обещания проверки не ослабляет обязательность локализации, правил валидации
и остальных действующих требований. Рекомендации по тестированию соответствуют
установленной инфраструктуре и используемым практикам.
- Обозначить исторический статус заменённых частей архитектурного решения и
направить к текущей принятой процедуре с сохранением rationale и правил
ведения ADR. Изменяемая информация о каталоге интерфейса принадлежит его
документу-владельцу; workflow ссылается на него. Использование уже поставленного
каталога не требует повторного согласования его добавления.
- Сохранить содержание всех переносимых требований к структуре и границам
модулей, локализации и валидации, стилю компонентов с действующими исключениями,
типизированному доступу к данным и авторизации, разделению данных и
представления, состояниям и доступности интерфейса, восстановлению после
ошибок и тестированию. Canonical gates, отдельные approvals, fingerprints,
независимая Validation и Finalization сохраняются.
Ожидаемый результат — применимые правила доступны до зависящего от них решения,
актуальные ссылки разрешаются, а перенос не оставляет двух поддерживаемых копий
и не теряет требований. Это относится как к отдельной UI-, API- или testing-задаче,
так и к расширению scope и recovery; неприменимые conventions остаются вне
обязательного контекста несвязанной задачи.
Вне scope:
- Изменение runtime-поведения frontend/backend и массовый рефакторинг conventions.
- Восстановление bundle budget checker, разрешение связанного spec drift,
восстановление backend e2e и переработка переносимости Completion Evidence.
- Новые instruction layers, реестры conventions, метаданные, context frameworks,
CI, hooks, dispatchers, checker frameworks, telemetry или dependency packages.
- Scoring, числовой token gate или ограничение числа обязательных документов.
- Перенос всей документации проекта в локальные инструкции frontend.
- Переписывание historical archives, dated audit snapshots и старого Completion
Evidence; их прежние пути сохраняют исторический контекст.
- Повторный аудит workspace и последующие этапы улучшения guardrails.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `specs/openspec-authority-and-lifecycle/spec.md`: уточнить обязательный выбор
контекста по фазе и фактическому scope, моменты повторной проверки маршрутизации
и применимость локальных conventions без пропуска обязательных inputs или
обхода approval boundaries. Остальные действующие инварианты сохраняются.
## Impact
Изменение затрагивает AI-агентов, разработчиков, Change Owner и независимых
reviewers, которые планируют, выполняют и проверяют работу в workspace.
- **frontend** — расположение и маршрутизация локальных conventions, точность
инструкций и действующая навигация; поведение приложения не меняется.
- **docs** — context discipline, актуальные workflow summaries, навигация и
историческая квалификация заменённых частей ADR при сохранении общей authority.
- **specs** — canonical planning и уточнение существующего требования о выборе
контекста; без дублирующего planning store и изменения исторических artifacts.
- **backend — N/A**: первый этап не меняет его поведение, контракт, реализацию,
локальные инструкции или gate.
Согласование направления не является approval этого Proposal или downstream
artifacts. Каждый следующий artifact и переход роли сохраняет отдельную
границу canonical lifecycle.
@@ -0,0 +1,243 @@
## MODIFIED Requirements
### Requirement: Agent context следует текущей SDD-фазе
Project-owned agent workflow SHALL начинать каждую работу с определения текущей
SDD-фазы и affected delivery areas, а затем SHALL загружать canonical artifacts,
локальные routing instructions и supporting authority, применимые к этой фазе и
этим areas. Переход в новую agent-сессию после утверждения planning artifact
SHALL использовать canonical artifact и явное решение Change Owner как основание
продолжения. Workflow MUST NOT требовать полную историю предыдущей сессии или
отдельный handoff/context artifact, дублирующий OpenSpec authority. Supporting
material, не влияющий на текущую фазу, затронутую область, доменную семантику или
обязательный gate, SHALL загружаться только при появлении наблюдаемой
необходимости.
Workflow SHALL проверять применимость контекста до первого зависимого решения,
при обнаружении новой области или границы и перед handoff по фактическому scope,
включая staged, unstaged и новые файлы. Проверка SHALL учитывать содержание
изменения, а не только первоначальное описание задачи или наличие staging.
Обнаружение нового scope MUST NOT разрешать выход за утверждённые planning
boundaries: требующее пересмотра intent, behavior, architecture или Tasks
расширение SHALL возвращаться к самой ранней затронутой boundary до зависимой
работы.
Workflow MUST получать все возвращённые native contextFiles, применимые risk
inputs и согласованный Design package текущей фазы независимо от количества
документов. Это правило SHALL действовать и при recovery; уже прочитанные
неизменившиеся inputs в том же контексте MAY переиспользоваться по существующей
context discipline. Недоступный обязательный input MUST оставаться явным
блокером зависимого решения, а не заменяться историей чата или догадкой.
Маршрутизация MUST опираться на существующую context authority и MUST NOT
вводить параллельный workflow, scoring, числовой token gate или лимит числа
обязательных документов.
#### Scenario: Утверждённый artifact передаётся в новую сессию
- **WHEN** Change Owner явно утверждает текущий planning artifact и следующая agent-сессия продолжает change на разрешённой downstream-фазе
- **THEN** workflow восстанавливает работу из canonical change artifacts, owner approval и применимых routing instructions без обязательной передачи полной истории обсуждения или создания второго handoff artifact
#### Scenario: Работа ограничена одной delivery area
- **WHEN** текущая фаза затрагивает одну определённую delivery area и не требует cross-system или доменной оценки
- **THEN** workflow загружает authority и локальные instructions этой area, а нерелевантные architecture, domain и repository materials остаются lazy context
#### Scenario: Supporting material становится необходимым
- **WHEN** во время работы возникает вопрос, влияющий на domain semantics, cross-area architecture, audit disposition или обязательный gate
- **THEN** workflow загружает соответствующий authoritative material до принятия решения и не использует token-efficiency guidance как основание пропустить требуемый контекст
#### Scenario: Предстоит первое зависимое решение
- **WHEN** агент определил фазу и affected areas и готовится принять первое
решение, зависящее от локальных правил или иной authority
- **THEN** он определяет применимые inputs по содержанию задачи и получает их
до этого решения, даже если работа ещё не создала diff
#### Scenario: В пределах approved scope обнаружена новая граница
- **WHEN** текущая работа выявляет ранее неучтённую границу, уже разрешённую
утверждёнными planning artifacts
- **THEN** workflow пересматривает применимость контекста и получает недостающие
правила этой границы до зависимой работы, не требуя нового approval только
из-за уточнения набора чтения
#### Scenario: Новая область требует расширения approved scope
- **WHEN** обнаруженная область требует изменения утверждённого intent,
behavior, architecture или checklist
- **THEN** workflow загружает применимый контекст для оценки расхождения,
сообщает основание возврата к самой ранней затронутой planning boundary и
не начинает зависимую работу до требуемого approval
#### Scenario: Перед handoff есть изменения вне staged diff
- **WHEN** агент готовит handoff, а unstaged или новые файлы выявляют
применимую область или правило, отсутствующие в первоначальном scope
- **THEN** workflow учитывает их вместе со staged изменениями, пересматривает
обязательный контекст и передаёт фактический scope и оставшиеся blockers;
отсутствие staging не позволяет пропустить эту область
#### Scenario: Recovery требует нескольких обязательных источников
- **WHEN** новая сессия восстанавливает разрешённую фазу, для которой нужны
native contextFiles, risk inputs и согласованный Design package
- **THEN** она получает весь применимый набор и именно согласованную версию
Design, даже если краткий handoff перечисляет лишь часть источников или
документов больше, чем агент первоначально ожидал прочитать
#### Scenario: Обязательный материал недоступен
- **WHEN** необходимый для текущего решения input отсутствует либо согласованную
версию Design package нельзя однозначно восстановить
- **THEN** workflow сообщает конкретный blocker и не принимает зависимое решение
до восстановления input либо разрешения вопроса на нужной planning boundary
## ADDED Requirements
### Requirement: Frontend-local conventions имеют единый вход по содержанию изменения
Участник workflow SHALL получать один актуальный набор frontend-local
conventions через компактный router, выбирающий документы по содержанию
изменения. Router SHALL направлять создание или перенос module/slice и
cross-slice imports к правилам структуры; declarations, props, exports,
продуктовые строки и сообщения валидации — к conventions; разделение
container/VM/view, формы, query states, skeleton и retry/recovery — к правилам
UI; HTTP client, generated types, query/mutation, Bearer и refresh — к правилам
API; изменение самих тестов или поведения с testing risk trigger — к правилам
тестирования. При сочетании triggers участник MUST получать все применимые
документы, а не выбирать один взаимоисключающий маршрут.
Router MUST ссылаться на применимые общие authorities вместо пересказа их
содержания. Несвязанная задача MUST NOT требовать чтения всех conventions.
Перенос SHALL сохранять полезное содержание всех шести существующих документов,
обязательные требования и действующие исключения, не оставляя двух
поддерживаемых копий. Общая engineering policy, architecture, ADR и as-built
documentation MUST сохранять своих существующих владельцев.
Все применимые действующие ссылки SHALL разрешаться к актуальным владельцам
после переноса, включая ссылки из optional entrypoints, если они затронуты.
Historical archives, dated audit snapshots и прежнее Completion Evidence
MUST оставаться неизменными; их старые пути MUST NOT считаться действующей
навигацией, требующей переписывания.
#### Scenario: UI-only изменение не затрагивает данные и testing risk
- **WHEN** задача меняет только представление и props существующего компонента
без изменения структуры модулей, взаимодействия с сервером, тестов или
поведения с testing risk trigger
- **THEN** router направляет к UI rules и component conventions; правила API,
структуры и тестирования не становятся обязательными только из-за того,
что изменён frontend
#### Scenario: Меняются ownership авторизации или refresh
- **WHEN** задача затрагивает формирование авторизации запросов либо
координацию refresh
- **THEN** router включает правила API и тестирования по security/concurrency
risk, а применимый contract и domain context остаётся обязательным независимо
от того, меняется ли интерфейс
#### Scenario: Меняются сами тесты
- **WHEN** задача изменяет тесты без изменения production behavior
- **THEN** router включает testing conventions; отсутствие изменения
production behavior не отменяет этот trigger, а остальные документы
выбираются по фактически затронутым границам
#### Scenario: Создаётся slice с импортом другого slice
- **WHEN** задача создаёт или переносит slice либо меняет cross-slice import
- **THEN** router включает structural conventions и public API boundaries до
решения о размещении или импорте, даже если внешнее поведение сохраняется
#### Scenario: Меняются только продуктовые строки или сообщения валидации
- **WHEN** задача меняет продуктовый текст либо пользовательские сообщения
валидации без изменения структуры, UI-state logic, API и тестов
- **THEN** router включает conventions локализации и валидации, не требуя
остальных локальных документов при отсутствии их triggers
#### Scenario: Задача не относится к frontend conventions
- **WHEN** задача исправляет опечатку в общей документации, не меняет
frontend-local instructions и не затрагивает frontend behavior или boundaries
- **THEN** workflow использует применимые общие и docs-local authorities без
обязательного чтения frontend conventions
#### Scenario: Участник открывает действующую ссылку после переноса
- **WHEN** участник переходит к frontend-local conventions из действующей
навигации после их переноса
- **THEN** ссылка ведёт к единственному актуальному владельцу, где сохранены
применимые требования и исключения; прежнее расположение не содержит второй
поддерживаемой копии правил
#### Scenario: Старый путь встречается в historical artifact
- **WHEN** прежний путь к conventions встречается в historical archive,
dated audit snapshot или старом Completion Evidence
- **THEN** artifact и его историческая ссылка сохраняются без переписывания;
актуальный маршрут определяется действующими entrypoints
### Requirement: Активные инструкции точно описывают обязательность и способ обеспечения правил
Активные instructions SHALL различать автоматическое блокирование существующим
gate, обязательную процедуру или semantic review и необязательное guidance.
Инструкция MUST NOT обещать автоматическую проверку, которой нет в текущей
конфигурации. Отсутствие автоматической проверки MUST NOT превращать обязательное
требование локализации, валидации или другой действующий invariant в пожелание.
Успех structural checks MUST NOT объявляться semantic approval или независимым
Validation PASS.
Testing guidance SHALL соответствовать объявленным зависимостям и используемой
тестовой инфраструктуре; наличие пакета только как транзитивной зависимости
MUST NOT представляться как его прямое принятие проектом. Исправление guidance
в рамках этого этапа MUST NOT требовать нового dependency package или менять
runtime behavior.
Заменённые части действующего ADR SHALL иметь явную историческую квалификацию
и направление к текущей принятой процедуре с сохранением rationale и правил
ведения ADR. Документация MUST опираться на фактическое состояние, а не повторять
неподтверждённое утверждение об удалении или наличии инструмента. Числовые
детали UI-каталога SHALL принадлежать его документу-владельцу; workflow summaries
SHALL ссылаться на него. Использование уже поставленного каталога MUST NOT
трактоваться как новое добавление, требующее повторного approval установки.
#### Scenario: Автоматическая проверка не блокирует непереведённую строку
- **WHEN** текущие style и type checks допускают произвольную непереведённую
продуктовую строку
- **THEN** инструкции сохраняют обязательную локализацию и semantic review,
не обещают автоматический запрет такой строки и не считают успешный gate
доказательством выполнения этого требования
#### Scenario: Нарушение действительно блокируется существующим gate
- **WHEN** текущее tooling детерминированно отклоняет нарушение документированного
component style в применимом scope
- **THEN** инструкция описывает это правило как автоматически проверяемое
с сохранением действующих исключений, не переводя его в guidance
#### Scenario: Рекомендованный testing helper не объявлен напрямую
- **WHEN** прежнее guidance рекомендует helper из пакета, не объявленного
прямой dependency, а действующие тесты используют доступные средства
- **THEN** guidance согласуется с принятой инфраструктурой без добавления
пакета ради прежнего текста; транзитивная доступность описывается точно
#### Scenario: Часть ADR описывает заменённую процедуру
- **WHEN** описание схемы или orchestration в ADR отличается от текущей
принятой процедуры
- **THEN** участник может отличить историческую часть решения от действующей
процедуры и перейти к её authority; rationale и исторические artifacts
сохраняются, а неподтверждённое удаление инструмента не утверждается
#### Scenario: Workflow ссылается на существующий UI-каталог
- **WHEN** участник использует уже поставленный каталог как reference
- **THEN** он получает актуальные числовые детали у документа-владельца без
второй копии в workflow и без повторного approval добавления каталога;
требования к отдельному approval Design package и независимой Validation
продолжают действовать
@@ -0,0 +1,57 @@
## 1. Frontend-local conventions и входы
До первых delivery edits сохранить в рабочем контексте исходный inventory по
context authority, включая staged/unstaged/new inputs и bytes защищённых
historical paths для проверки 3.2. Новый versioned artifact для этого не нужен.
- [x] 1.1 [frontend] Перенести все шесть документов из `docs/` непосредственно в `.ai/`, сохранив одноимённых владельцев и содержание по таблице сохранения в Design; убрать прежний поддерживаемый набор без redirect stubs и второго registry.
- **Covers:** `openspec-authority-and-lifecycle` — `Frontend-local conventions имеют единый вход по содержанию изменения`
- **Verification:** Сопоставить исходные документы и перенос по каждой строке таблицы Design, включая FSD без widgets, i18n/validationMessage, React/Storybook exceptions, typed API/auth/refresh, VM/view, cached snapshot, skeleton accessibility, recovery и MSW/risk-based testing; проверить наличие только шести согласованных файлов непосредственно в `.ai/` и отсутствие второй поддерживаемой копии в `docs/`.
- [x] 1.2 [frontend] Сделать `.ai/README.md` компактным router с объединением triggers и ссылками на общие authorities; направить к нему `AGENTS.md` и README приложения, обновив их прямые ссылки на conventions и сохранив local gate и boundaries.
- **Covers:** `openspec-authority-and-lifecycle` — `Frontend-local conventions имеют единый вход по содержанию изменения`
- **Verification:** Проверить все пять маршрутов Design и сочетание нескольких triggers; проследить локальные входы до существующего target и применимого раздела, убедиться, что AGENTS/README не предписывают читать весь набор без triggers и не копируют общую policy.
- [x] 1.3 [frontend] Уточнить обязательность и enforcement в AGENTS и перенесённых conventions; согласовать TESTING с используемым `fireEvent` и объявленными зависимостями, а описание Storybook — с уже поставленной инфраструктурой.
- **Covers:** `openspec-authority-and-lifecycle` — `Активные инструкции точно описывают обязательность и способ обеспечения правил`
- **Verification:** Сверить автоматические утверждения и исключения с текущим `biome.json`, а testing guidance — с `package.json`, lockfile и интерактивными тестами; проверить сохранение mandatory i18n/validationMessage и React review conventions, точное описание транзитивного user-event, доступность существующего каталога без повторного approval установки и отсутствие изменений dependencies, runtime или gate.
## 2. Общая context authority и текущая документация
- [x] 2.1 [docs] Дополнить Routing context в `development/agent-token-efficiency.md` повторной сверкой до первого зависимого решения, при новой границе и перед handoff по staged/unstaged/new inputs; добавить короткие ссылки на эту процедуру в применимых фазах `development/openspec-authority-and-lifecycle.md`.
- **Covers:** `openspec-authority-and-lifecycle` — `Agent context следует текущей SDD-фазе`
- **Verification:** Проверить различие новой границы внутри approved scope и расширения intent/behavior/architecture/Tasks, полноту native contextFiles/risk inputs/Design package при recovery, reuse неизменившихся inputs и явный blocker при missing input; убедиться, что сохранены существующие fingerprints/approval boundaries и не созданы второй context map, workflow, scoring или лимит чтения.
- [x] 2.2 [docs] Уточнить Guardrails в `development/engineering-governance.md`: отделить автоматические Enforceable Engineering Invariants от обязательных процедур/semantic review и необязательного guidance, согласовав общую policy с frontend instructions.
- **Covers:** `openspec-authority-and-lifecycle` — `Активные инструкции точно описывают обязательность и способ обеспечения правил`
- **Verification:** Сопоставить governance с результатом 1.3: отсутствие checker не снимает явно обязательное требование, существующее автоматическое блокирование сохраняет свой статус, эстетические предпочтения остаются guidance; проверить сохранение canonical gates, approvals, fingerprints, независимой Validation и отсутствия новых checkers.
- [x] 2.3 [docs] Добавить ADR 011 `adr/011-native-openspec-lifecycle-with-repository-gates.md` о действующем native lifecycle и repo gates; обозначить частичную историческую замену ADR 010 и обновить ADR index, сохранив rationale и рассмотренные альтернативы.
- **Covers:** `openspec-authority-and-lifecycle` — `Активные инструкции точно описывают обязательность и способ обеспечения правил`
- **Verification:** Проверить соответствие `adr/README.md`, контекст/решение/последствия нового ADR, взаимные ссылки и указатели к текущим authorities; сверить формулировки schema v3/v5, прежних verifiers/dispatcher и сохранённого `.cli-version` с актуальными источниками, без изменения version contract, schema и untouched main requirements.
- [x] 2.4 [docs] Обновить текущие ссылки на frontend conventions в UI-каталоге и remediation registry; убрать дублируемое число групп каталога из frontend workflow, оставив ссылку на `development/ui-catalog.md` как owner этих сведений.
- **Covers:** `openspec-authority-and-lifecycle` — `Frontend-local conventions имеют единый вход по содержанию изменения`; `openspec-authority-and-lifecycle` — `Активные инструкции точно описывают обязательность и способ обеспечения правил`
- **Verification:** Проверить targets и anchors изменённых ссылок после переноса; в registry сравнить diff только live адресов conventions/UI pattern, сохранив Audit IDs, dispositions, статусы, даты, findings и evidence links; проверить отсутствие второй числовой сводки в workflow и сохранение Design approval/Validation при использовании существующего Storybook.
## 3. Implementation-owned проверка согласованного поведения
- [x] 3.1 [workspace] Пройти routing scenarios Delta Specs по итоговым instructions, проверяя выбор обязательного контекста и неприменимые контрпримеры без добавления тестов, повторяющих Markdown.
- **Covers:** `N/A` — задача не вводит самостоятельного observable behavior; это необходимая cross-area проверка frontend router и общей context discipline по решению 8 Design перед передачей реализации в независимую Validation.
- **Verification:** Для UI-only props/presentation проверить CONVENTIONS + UI-PATTERN без необоснованных API/STRUCTURE/TESTING; для auth/refresh — API-LAYER + TESTING и применимый contract/domain context; для изменения только тестов, slice/imports или строк — соответствующие triggers. Docs-only опечатка не должна требовать frontend conventions. Отдельно пройти первое решение без diff, supporting input по новой необходимости, новую границу внутри approved scope и расширение за его пределы, handoff с unstaged/new files, recovery полного обязательного пакета и missing-input blocker; сохранить observed outcomes в рабочем контексте без нового versioned registry или независимого verdict.
- [x] 3.2 [workspace] Проверить межрепозиторную навигацию и границы документационного переноса по фактическому inventory, включая сохранение исторических inputs и отсутствие непредусмотренных изменений.
- **Covers:** `N/A` — самостоятельное поведение не добавляется; согласованный перенос между независимыми репозиториями требует cross-area проверки ссылок и сохранения inputs по решениям 4 и 6 Design, которую один repository gate не покрывает.
- **Verification:** Найти старые paths в действующих entrypoints и проверить все применимые targets/anchors, включая optional entrypoints при наличии совпадений; отличить historical references и generated graph/cache outputs от live navigation. Сравнить historical archives, dated audits, прежнее Completion Evidence и датированные research/browser snapshots с исходным inventory по bytes; по staged/unstaged/new scope проверить сохранение root/optional routing при отсутствии затронутых ссылок, неизменность runtime/dependencies/gates и соблюдение read-only references policy.
## 4. Repository-owned delivery и canonical gates
Нормативный deliverable `specs` — canonical change, подготовленный Specification.
Design не предусматривает отдельную implementation-редакцию schema или main
specs: задача 4.3 проверяет эту repository-owned поставку. Semantic sync остаётся
на границе Finalization. Backend и `runtime-openapi` имеют `N/A` по Delivery Map.
Независимая Validation не является checkbox этой реализации.
- [x] 4.1 [frontend] Выполнить canonical frontend gate на итоговом frontend delivery после переноса и уточнения инструкций.
- **Covers:** `N/A` — новое observable behavior не вводится; repository-owned проверка обязательна для affected frontend delivery по Delivery Map, хотя runtime и gate остаются прежними.
- **Verification:** Из `frontend` выполнить `nvm use --silent`, lockfile install `npm ci` и `npm run verify`; сохранить exact commands, cwd, exit status, существенный observed output и relevant input inventory/fingerprint по context authority. Проверить, что tracked outputs не изменены самим non-mutating gate.
- [x] 4.2 [docs] Выполнить canonical docs gate на итоговой policy, ADR и действующей навигации.
- **Covers:** `N/A` — это необходимая repository-owned проверка affected docs delivery по Delivery Map, не отдельное spec-level поведение и не замена scoped link review из 2.4 и 3.2.
- **Verification:** Из `docs` выполнить `nvm use --silent`, lockfile install `npm ci` и `npm run verify`; сохранить exact commands, cwd, exit status, существенный observed output и relevant input inventory/fingerprint по context authority, без запуска auto-fix как доказательства корректности.
- [x] 4.3 [specs] Проверить полноту и целостность repository-owned canonical change на текущем native inventory и выполнить specs gate, сохраняя утверждённые planning inputs.
- **Covers:** `N/A` — нормативное specs delivery уже выражено canonical artifacts этого change; задача обеспечивает его обязательную repository-owned structural проверку по Delivery Map без дополнительной мутации ради участия specs, нового поведения или переноса completion operations в Implementation.
- **Verification:** Активировать frontend `.nvmrc` через `nvm use --silent` до перехода в `specs`; выполнить `openspec status --change clarify-agent-context-routing --json --store wealth-ledger-specs`, `openspec schema validate wealth-ledger-sdd --verbose`, `openspec validate clarify-agent-context-routing --type change --strict --json --store wealth-ledger-specs` и `openspec validate --specs --strict --store wealth-ledger-specs`. По native inventory сверить approval bindings с действующей нормализацией task progress; сохранить exact commands, cwd, exit status, существенный observed output и relevant input inventory/fingerprint. При изменённом planning input вернуть работу к ответственной boundary, не исправлять approved содержание неявно.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-09-09
@@ -0,0 +1,214 @@
## Context
Страница Brokerage Account уже разделяет получение данных в
`AccountPage.vm.ts` и отображение в `AccountPageLoaded.view.tsx`.
`PortfolioTotals` получает готовые `totalsByCurrency`; текущая сводка использует
Mantine `SimpleGrid` и `Paper`, вертикальную метку ISO над суммой, отступ 16 px
и верхнюю синюю границу 3 px. Максимальная ширина контента страницы — 960 px.
`shared/lib/display.ts` уже форматирует decimal string без числового
преобразования: сохраняет знак и дробные позиции, группирует целую часть.
`formatMoney` меняет только RUB на ₽. Query и VM уже сохраняют успешные данные
при refetch/error; `PortfolioRefreshState` отделён от server-side `isStale`.
Действующий initial skeleton содержит общий блок высотой 120 px вместо
геометрии валютных карточек.
Основание подхода — утверждённые [Proposal](proposal.md) и
[Delta Specs](specs/portfolio-view/spec.md). A1 из Discovery выбран владельцем;
ни выбор, ни прежние PNG сами по себе не были approval Design.
## Goals / Non-Goals
**Goals:** изменить представление сводки и её loading placeholder в существующем
page slice; сохранить строковую точность и валютную идентичность; использовать
нынешние primitives, theme tokens и границы VM/view.
**Non-Goals:** менять финансовые вычисления, транспортные типы, query/cache
policy, state machine, общие правила денежных суффиксов приложения или соседние
разделы страницы. Новые зависимости, инструментальные витрины и сервисы
форматирования не нужны.
## Decisions
### Ownership и поток данных
`PortfolioTotals` остаётся владельцем компактного представления каждого
переданного итога. Порядок, membership и ключи берутся из исходных валют;
никакой повторной агрегации, сортировки по знакам или дополнения валют нет.
VM продолжает выбирать loaded/empty по составу Portfolio, а не по итогам.
Пустой список итогов не создаёт карточек или отдельного empty-состояния.
Loaded view сохраняет своё место сводки после времени снимка и перед составом.
Его timestamp, refresh/error и stale sections продолжают получать прежние
props. Меняется только сводка и соответствующая область page-owned skeleton.
Это удерживает ответственность за устойчивость данных в query/VM, а за
геометрию — в presentation.
### Точное форматирование и валютная идентичность
Переиспользовать существующий `formatDecimal`, открыв его через публичный
`shared/lib` export без изменения алгоритма и поведения `formatMoney`.
Выбор метки и суффикса принадлежит сводке: RUB → ₽, USD → $, EUR → €;
для остальных валют метка и суффикс — исходный ISO-код. Например, USD/CAD
дают `$`/`CAD`, поэтому совпадающие мировые знаки не требуют каталога валют
или анализа всей коллекции. Неизвестные валюты не отбрасываются.
Полные названия трёх валют принадлежат существующему русскому i18n-каталогу.
Семантика карточки — пара название/значение (`dl`, `dt`, `dd` либо эквивалентная
связь): доступное название содержит «Российский рубль», «Доллар США» или «Евро»;
для прочих валют доступен ISO-код. Декоративный badge скрыт от accessibility
tree, полное текстовое значение остаётся доступным. Карточка не кнопка,
не получает tab stop и не требует tooltip/hover для определения валюты.
Числа не проходят через `Number`, `parseFloat`, округление или currency-mode
`Intl.NumberFormat`. Пробелы группировки и десятичная запятая сохраняются;
перенос — CSS layout, а не вставка символов в числовые данные. `-0.5` остаётся
`-0,5`, исходные дробные нули сохраняются. Замена ISO-кода в уже собранной строке
`formatMoney` отвергнута: она связывала бы сводку с деталями суффикса общего
formatter. Новый дубликат decimal formatter также не нужен.
### Компоновка средствами платформы
Mantine `Paper` и page-local CSS остаются основой карточек. Сетка сохраняет
колонки `base: 1`, `sm: 2`, `md: 3` (48 em / 62 em), с gap 10 px.
Одна валюта занимает одну ячейку сетки, как в A1; дополнительные валюты
заполняют следующие строки, без фиктивных пустых карточек.
Внутри карточки метка и сумма образуют переносимую строку. Естественная ширина
полного текста определяет вместимость, без JS-измерений и порогов по количеству
цифр. При недостатке места сумма переходит ниже метки и получает всю доступную
ширину. Ограничения `min-width: 0`, `max-width: 100%`, обычные переносы на
пробелах и `overflow-wrap: anywhere` защищают также длинную дробную часть.
Не задаются фиксированная высота, `nowrap`, ellipsis, clipping или уменьшение
шрифта ради вместимости. На переносимой строке знак минуса не отделяется
от первой цифры; остальные цифры и суффикс остаются текстом в исходном порядке.
Альтернативы: единая общая полоса не соответствует выбранным отдельным карточкам;
фиксированная двухстрочная раскладка теряет компактность обычных значений;
обрезание/скролл/округление нарушают Delta Specs. Нативный reflow сохраняет
простую архитектуру и работает при увеличении текста.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — сводка и её initial skeleton на
`/accounts/:accountId`. Навигация, heading, время, позиции, Cash Balance и
остальные секции на PNG служат контекстом; их внешний вид не является заданием
на редизайн. Наличие в runtime дополнительных секций не является расхождением.
**Package revision:** `a1-symbols-r1`. Один approval относится ко всему этому
Markdown и восьми локальным PNG ниже. Они доступны без Pencil, Storybook,
исходного чата или внешнего аккаунта.
**Reused source and differences:** это новый canonical пакет, а не ссылка на
approved archived reuse. PNG перенесены byte-for-byte из выбранных Discovery r3
exports; их первоначальные имена перечислены ниже. Исходный `.pen` не является
зависимостью пакета. Markdown дополняет изображения правилами для других валют,
accessibility, промежуточных ширин и произвольно длинных значений.
### Визуальная геометрия
Карточка белая, граница gray-3 1 px со всех сторон, radius 8 px, без отдельной
синей верхней полосы и тени. Padding 12 px; промежуток метка/сумма 10 px.
Слева badge: фон blue-0, текст blue-6, radius 4 px, минимум 40 × 24 px;
для ISO-кодов ширина растёт по тексту. Знак 18 px / 600. Сумма — 20 px / 600,
line-height 1.55, основной цвет dark-7. Используется системный font stack
приложения, без загрузки Roboto только ради совпадения canvas.
Обычная строка выравнивает сумму к правому краю. После перехода под метку
сумма использует всю ширину с чтением слева направо. Предпочтительны переносы
на пробелах группировки; длинная непрерывная дробь может переноситься между
цифрами. Точная точка переноса зависит от системного шрифта: PNG не задаёт
обязательный разрыв после конкретной цифры. Все цифры, минус и суффикс читаемы.
При базовом масштабе обычная карточка ориентировочно 57 px, три карточки
на mobile — 191 px вместе с двумя gap. Это design geometry, а не runtime
замер: критерием служат полные значения и уменьшение высоты относительно
прежнего UI при одинаковых ширине, шрифте, масштабе и данных. Длинные значения
и увеличение текста закономерно увеличивают высоту.
### Screens, states and interactions
| Состояние | Решение |
| --- | --- |
| Loaded, 1/3 валюты | Отдельные A1 карточки; символ слева, полная сумма справа при вместимости. |
| Loaded, больше валют / неизвестная валюта | Та же сетка без лимита количества; прочие валюты используют ISO-код в badge и суффиксе. |
| Длинные / отрицательные значения | Сумма уходит под badge и переносится; `-0,5 $` сохраняет минус; зелёный/красный тон по знаку не добавляется. |
| Initial loading | Три декоративных card placeholders с badge/amount shapes, в той же responsive сетке; число shapes не обещает три валюты. Существующий `LoadingSkeleton` владеет busy label, shapes скрыты от чтения и не фокусируются. Остальные semantic regions loading view сохраняются. |
| Refetch | Прежние карточки и `lastSuccessfulAt` плюс существующий compact progress; карточки не заменяются skeleton. |
| Client refresh error | Прежние значения и время плюс существующий orange warning и «Повторить». |
| Server stale | Прежний blue warning и время; само `isStale` не создаёт retry. |
| Error + stale | Оба самостоятельных предупреждения, карточки и время; retry принадлежит клиентской ошибке. |
| Retry → success | До ответа сохраняются значения; успешный ответ обновляет значения/время, снимает client error; stale зависит только от нового ответа. |
| Нет итогов при непустом составе | Нет карточек; состав и время остаются. |
| Пустые позиции и Cash Balance | Существующее empty-представление, без нулевых карточек. |
| Ошибка без успешных данных | Существующая error view; `404` ведёт к списку счетов без retry; ошибки подключения/токена сохраняют свои действия, временная/неизвестная ошибка допускает retry. |
Новых взаимодействий нет. Кнопка повтора сохраняет клавиатурную доступность
и существующую callback-связь с VM. Статические суммы не превращаются в live
region; обновление сообщает существующий status region.
**Responsive behavior:** на 320/390 px одна карточка в ряду; при 1440 px и
контенте 960 px — три. Между ними действуют указанные breakpoints. При любом
числе колонок переполнение значения переводит его ниже метки, а не расширяет
grid track. Одна валюта на узком экране занимает всю ширину. Loading использует
те же правила; refetch/error/stale не меняют геометрию самих итогов.
### Materials
| Material ID (Discovery source) | Relative path | Screen/state and purpose | SHA-256 |
| --- | --- | --- | --- |
| wide (ACV5F) | [visuals/a1-wide.png](visuals/a1-wide.png) | 1440, три обычных итога | `b406552e01098a5b95183d6a54bd59b3bf827387d33d73a6a453d83140b8da10` |
| narrow (j6ks4) | [visuals/a1-narrow.png](visuals/a1-narrow.png) | 390, три обычных итога | `8ee71bb406abaa980e08f57c45e007fb66183786dfd251c9280a6ff62205d922` |
| single (iwyJA) | [visuals/a1-single-wide.png](visuals/a1-single-wide.png) | 1440, одна валюта | `6f163dd4a4ba461df5e715bab5757f4698986aade56ae38f02585d9c8d366b28` |
| long-wide (Hbu1s) | [visuals/a1-long-wide.png](visuals/a1-long-wide.png) | 1440, длинные значения | `ecccaee124c72b0f4f22bd92b31ef97e260c830782761ed9e7aab4c33a7e0af0` |
| long-320 (vjlkn) | [visuals/a1-long-320.png](visuals/a1-long-320.png) | 320, перенос и сохранение всех цифр | `48d62eaa497819e6312f5ae56afa393f92c7bb0970543cd9b3abdd5960480534` |
| loading (DuAlU) | [visuals/a1-loading-narrow.png](visuals/a1-loading-narrow.png) | 390, placeholders сводки | `138b000b334f54294f6cd8e88d607619608b05620231267fc1dc0477e1f87f46` |
| refresh (aP2cU) | [visuals/a1-refresh-narrow.png](visuals/a1-refresh-narrow.png) | 390, сохранение данных при обновлении | `e453606105a96290a2d2358e039935c89187a90127ffe4228374ae6e16c9e252` |
| error-stale (z5EeB) | [visuals/a1-error-stale-narrow.png](visuals/a1-error-stale-narrow.png) | 390, отдельные предупреждения и retry | `c63c8a30cc932582f591422dd4871c6663cb55567218cee988d7119d77d4a4e3` |
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | A1 totals и соответствующий skeleton; точное форматирование с локальными знаками и доступной идентичностью; сохранённые query/VM состояния. |
| backend | `N/A` | Готовые итоги, расчёты, Snapshot и HTTP/OpenAPI contract не меняются; серверная реализация не требуется. |
| specs | `affected` | Canonical planning, переносимый Design package и нормативное описание `portfolio-view` в рамках lifecycle. |
| docs | `affected` | После реализации `features/portfolio-view.md` описывает фактическую компактную сводку, валютные знаки и ограничения оценки. |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `N/A` | Ни одна сторона API contract не меняется; существующие decimal strings и исходные currency остаются входом представления. |
## Risks / Trade-offs
- Разные системные шрифты и zoom меняют вместимость. Нужен browser reflow review
обычных и длинных fixtures на узких и широких экранах; PNG не доказывает
отсутствие runtime overflow или доступность. Точность и полнота важнее высоты.
- Открытие существующего decimal formatter немного расширяет публичный
`shared/lib`, зато исключает дублирование денежных преобразований. Риск
защищается сохранением текущего `formatMoney` и regression cases для точных
цифр, дробных нулей, `-0.5` и валютной идентичности на presentation boundary.
- Значение `$` без доступного полного названия неоднозначно; связь пары
название/значение проверяется в accessibility tree. Цвет не несёт идентичность.
- Placeholder не знает количества валют заранее. Три формы повторяют ожидаемую
структуру, а успешный ответ определяет реальное число карточек.
- Геометрия и денежное представление требуют scoped acceptance проверки;
состояние refetch/error проверяется через существующие account-page tests,
без новой state machine или отдельной инфраструктуры тестирования.
## Migration Plan
После approval Design и затем Tasks представление и loading geometry
поставляются совместно с проверкой точности и сохранённых состояний. После
проверенной реализации docs фиксирует as-built поведение; canonical спецификация
синхронизируется в рамках штатного lifecycle. Миграции данных, переключатель
функции и координированный релиз backend не нужны.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `N/A` |
| Reason | Изменение обратимого frontend presentation без изменения auth, данных, API или другого труднообратимого решения. Обычный откат frontend delivery возвращает прежний вид без преобразования данных. |
@@ -0,0 +1,55 @@
## Why
Сводка итогов Portfolio на странице Brokerage Account занимает избыточную
высоту, особенно на узком экране, и отдаляет пользователя от состава портфеля.
Нужно сделать её компактнее, сохранив читаемость каждой суммы и понимание того,
к какой валюте она относится. В Discovery владелец выбрал направление A1:
отдельные компактные валютные карточки со знаками ₽, $ и €.
## What Changes
- Представить итоги Portfolio компактными отдельными карточками по валютам,
следуя выбранному направлению A1. На широком и узком экране обычная сводка
занимает меньше высоты, сохраняя читаемость и доступность значений.
- Для российского рубля, доллара США и евро показывать ₽, $ и € соответственно
в валютных метках и рядом с суммами вместо RUB, USD и EUR. Сохранить
однозначность валюты и её полное доступное название; другие валюты остаются
различимыми и не исключаются из сводки.
- Показывать длинные, дробные и отрицательные суммы полностью: без потери
цифр или знака, округления, сокращений до тысяч или миллионов, обрезания
и необходимости горизонтальной прокрутки. На узком экране допускать перенос
и увеличение высоты ради полного значения.
- Сохранить смысл каждой суммы: итог позиций и доступных денег в одной валюте,
без сложения разных валют и конвертации. Отсутствующие суммы не заменяются
нулями, рублёвый итог не становится общей стоимостью Portfolio, а сводка
не обещает полноту оценки при отсутствии стоимости отдельных позиций.
- Сохранить существующие состояния: первичное ожидание без вымышленных сумм;
прежние суммы и время успешного получения при фоновом обновлении и его
ошибке; раздельные сообщения об устаревшем снимке и ошибке обновления,
с применимым действием повтора. Существующее пустое состояние сохраняется.
Вне scope: изменение денежных расчётов, состава или источников данных;
редизайн навигации, заголовка, позиций и соседних разделов; изменение
форматирования денег во всём приложении. Выбор A1 задаёт направление этой
сводки; детальное визуальное решение подлежит отдельному согласованию Design.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `specs/portfolio-view/spec.md`: добавить требования к компактному
представлению валютных итогов на странице Brokerage Account, символам
валют, доступной идентичности и полному отображению сумм на широком
и узком экране при сохранении существующего смысла итогов и состояний.
## Impact
Изменение затрагивает пользователей, просматривающих Portfolio одного
Brokerage Account на широких и узких экранах, и ограничено сводкой его
валютных итогов. При поставке обновляются пользовательское представление,
его нормативное описание и документация фактически доступного поведения.
Способ получения данных и финансовый смысл итогов сохраняются.
@@ -0,0 +1,251 @@
## ADDED Requirements
### Requirement: Компактная сводка валютных итогов Portfolio
На странице Brokerage Account клиент SHALL показывать каждый полученный
валютный итог Portfolio отдельной компактной карточкой. При достаточной
ширине карточки валютная метка и сумма MUST располагаться в одной строке;
сумма MUST оставаться основной читаемой величиной. Обычная сводка с одной
или тремя валютами MUST занимать меньше высоты, чем прежнее представление
с меткой над суммой при тех же значениях и ширине экрана.
На широком экране несколько карточек SHALL располагаться рядом при наличии
места; на узком экране MUST сохраняться одна карточка в ряду. Если полное
значение не помещается рядом с меткой, клиент MUST предоставлять ему полную
доступную ширину карточки и переносить содержимое с увеличением высоты.
Сводка MUST NOT обрезать суммы, заменять их многоточием или требовать
горизонтальной прокрутки для чтения карточек и значений.
#### Scenario: Обычная сводка на широком экране
- **WHEN** User открывает Portfolio с итогами `125000.50` RUB, `320.00` USD
и `9876.54321` EUR на широком экране с местом для трёх карточек
- **THEN** клиент показывает три отдельные карточки рядом, метку и полную
сумму в одной строке каждой карточки, с меньшей высотой сводки, чем при
прежнем размещении метки над суммой в тех же условиях
#### Scenario: Обычная сводка на узком экране
- **WHEN** User открывает тот же Portfolio с тремя валютными итогами
на узком экране
- **THEN** клиент показывает три карточки по одной в ряду, сохраняя полные
читаемые суммы и меньшую общую высоту сводки, чем при прежнем размещении
метки над суммой в тех же условиях
#### Scenario: Portfolio с одной валютой
- **WHEN** Portfolio содержит один валютный итог
- **THEN** клиент показывает одну компактную карточку без пустых карточек
для других валют; обычная сумма и метка помещаются в одну строку при
достаточной ширине на широком и узком экране
#### Scenario: Длинная сумма на узком экране
- **WHEN** User просматривает итог `123456789012.345678901` RUB при ширине
экрана 320 пикселей и значение не помещается рядом с валютной меткой
- **THEN** сумма получает полную доступную ширину карточки и при необходимости
переносится; все цифры и знак валюты читаемы без обрезания, многоточия
и горизонтальной прокрутки
### Requirement: Обозначение и доступная идентичность валюты в сводке
В валютных метках и суффиксах сумм сводки клиент MUST обозначать RUB знаком
`₽`, USD знаком `$`, EUR знаком `€` вместо видимых ISO-кодов этих валют.
Каждая сумма MUST оставаться однозначно связанной со своей исходной валютой,
включая чтение вспомогательными технологиями. Для RUB, USD и EUR MUST быть
доступны названия «Российский рубль», «Доллар США» и «Евро» соответственно.
Символ или цвет сами по себе MUST NOT быть единственным доступным способом
определения валюты.
Другие полученные валюты MUST оставаться в сводке с понятным обозначением
и доступной идентичностью. Если знак отсутствует или неоднозначен, клиент
MUST сохранять возможность различить исходную валюту по её названию или
ISO-коду, в том числе визуально; неизвестное полное название MUST NOT
подменяться вымышленным. Совпадающие знаки разных валют MUST NOT приводить
к объединению карточек или неразличимому представлению их валют.
#### Scenario: Рубль, доллар США и евро показаны знаками
- **WHEN** сводка содержит RUB, USD и EUR
- **THEN** метки показывают `₽`, `$` и `€`, а суммы заканчиваются теми же
знаками; видимые RUB, USD и EUR не подменяют выбранные знаки
#### Scenario: Валютная сумма читается вспомогательной технологией
- **WHEN** User читает валютный итог сводки вспомогательной технологией
- **THEN** ему доступны полное значение и однозначная валютная идентичность,
включая «Российский рубль», «Доллар США» или «Евро» для соответствующих валют
#### Scenario: Получена другая валюта
- **WHEN** сводка содержит валюту помимо RUB, USD и EUR
- **THEN** её отдельный итог остаётся доступным с понятным обозначением
и доступным названием либо исходным ISO-кодом при неизвестном названии
#### Scenario: У разных валют совпадает знак
- **WHEN** Portfolio содержит итоги двух исходных валют с одинаковым знаком
- **THEN** сводка показывает два отдельных итога, и User может различить
их валюты визуально и вспомогательной технологией без догадки по сумме
### Requirement: Полные точные значения и смысл валютной сводки
Сводка MUST показывать полученные валютные итоги без изменения их знака,
цифр и дробной точности. Для русской локали клиент SHALL сохранять группировку
разрядов пробелами и десятичную запятую, не округляя значение, не отбрасывая
дробные позиции и не сокращая сумму до тысяч, миллионов или экспоненциальной
записи. Требование MUST сохраняться для длинных, отрицательных значений
и отрицательных дробей с нулевой целой частью.
Каждая карточка MUST представлять только полученный итог позиций и Cash
Balance своей исходной валюты. Сводка MUST NOT конвертировать или складывать
разные валюты, показывать рублёвый итог как общую стоимость Portfolio,
повторно добавлять Cash Balance либо обещать полную оценку при наличии
позиций без вычислимой стоимости. Для отсутствующего валютного итога клиент
MUST NOT создавать нулевую карточку. Отсутствие итогов само по себе MUST NOT
обозначать пустой Portfolio, если позиции или Cash Balance присутствуют.
#### Scenario: Обычные суммы сохраняют дробные позиции
- **WHEN** в сводку поступают значения `125000.50` RUB, `320.00` USD
и `9876.54321` EUR
- **THEN** клиент показывает `125 000,50 ₽`, `320,00 $` и `9 876,54321 €`
с сохранением каждой исходной цифры и дробной позиции
#### Scenario: Длинные и отрицательные значения не теряют точность
- **WHEN** в сводку поступают `123456789012.345678901` RUB,
`-1234567.890123` USD и `987654321.000001` EUR
- **THEN** пользователь видит полные `123 456 789 012,345678901 ₽`,
`-1 234 567,890123 $` и `987 654 321,000001 €` без округления,
сокращений и потери отрицательного знака на широком и узком экране
#### Scenario: Отрицательная сумма меньше единицы
- **WHEN** полученный валютный итог равен `-0.5` USD
- **THEN** сводка показывает `-0,5 $`, сохраняя знак и дробную величину
#### Scenario: Несколько валют и денежные остатки
- **WHEN** полученные итоги RUB и USD уже включают позиции и Cash Balance
соответствующих валют
- **THEN** клиент показывает эти два итога без повторного добавления денег,
межвалютного итога или представления RUB как общей стоимости Portfolio
#### Scenario: Стоимость части позиций неизвестна
- **WHEN** Portfolio содержит позиции без вычислимой стоимости и доступные
валютные итоги, сформированные без этих позиций
- **THEN** сводка показывает только полученные итоги, не добавляет нули
за отсутствующие стоимости и не называет результат полной оценкой Portfolio
#### Scenario: Итоги отсутствуют при непустом Portfolio
- **WHEN** `totalsByCurrency` пуст, но `positions` или `cashBalances` непусты
- **THEN** клиент не создаёт валютные карточки и не заменяет доступный
состав Portfolio состоянием пустого портфеля
## MODIFIED Requirements
### Requirement: Состояния и ошибки Portfolio на клиенте
Клиент SHALL загружать Portfolio при открытии защищённого маршрута
`/accounts/:accountId`. Он MUST использовать успешный ответ в течение
30 секунд, без automatic retry, polling или refetch при возврате фокуса.
При загрузке без ранее полученного успешного ответа для этого же Brokerage
Account клиент MUST показывать структурное skeleton-состояние ожидаемого
содержимого, включая сводку, без вымышленных сумм. Область загрузки MUST
иметь доступное обозначение занятости; skeleton MUST NOT быть интерактивным
или получать клавиатурный фокус.
Истечение срока свежести MUST NOT само по себе скрывать уже доступный
Client Cached Snapshot. Во время фонового обновления и после его ошибки
клиент MUST сохранять ранее полученные данные того же Portfolio, включая
валютные итоги и исходное `lastSuccessfulAt`. При обновлении клиент MUST
показывать компактную неблокирующую индикацию без замены данных skeleton.
При ошибке обновления он MUST показывать локализованное неблокирующее
предупреждение о последних полученных данных и предоставлять действие
повторной попытки. Сохранённые данные MUST NOT считаться новым успешным
снимком или подтверждением текущей доступности Brokerage Account.
Клиент MUST показать штатное empty-состояние только при пустых `positions`
и `cashBalances`, без нулевых валютных карточек. Для показываемого состава
Portfolio клиент MUST сохранять время успешного получения снимка
`lastSuccessfulAt`, форматируемое в UTC. При `isStale: true` клиент MUST
показывать Snapshot, его исходное время и нейтральное предупреждение
о временной недоступности T-Bank. Это предупреждение само по себе MUST NOT
добавлять действие ручного обновления. Server-side stale и ошибка фонового
обновления Client Cached Snapshot MUST оставаться раздельными состояниями:
ошибка клиента MUST NOT менять или подменять `isStale`, а оба предупреждения
и действие повтора клиентского обновления MUST оставаться доступными при
одновременном наличии этих состояний.
При отсутствии ранее успешно полученного Client Cached Snapshot этого же
Portfolio и ответе `404` клиент MUST показать состояние недоступного счёта
с действием возврата к списку счетов без действия повтора. При отсутствии
Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE`
или иной ошибке клиент MUST показать безопасное error-состояние с доступными
для ошибки действиями; действие повтора разрешено только для временной
и неизвестной ошибки.
#### Scenario: Показ устаревшего Portfolio
- **WHEN** клиент получает успешный ответ с `isStale: true`
- **THEN** клиент показывает позиции, остатки, валютные итоги, время
последнего успешного снимка и нейтральное предупреждение; само это
предупреждение не добавляет действие ручного обновления
#### Scenario: Недоступный счёт на клиенте
- **WHEN** для текущего Portfolio отсутствует ранее успешно полученный
Client Cached Snapshot и клиент получает `404`
- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку
Brokerage Account без действия повтора
#### Scenario: Первичная загрузка Portfolio
- **WHEN** клиент ожидает Portfolio этого Brokerage Account без ранее
полученного успешного ответа
- **THEN** он показывает структурный skeleton, включая место сводки,
с доступным обозначением загрузки, без вымышленных или нулевых сумм
и без фокусируемых placeholders
#### Scenario: Обновление после истечения срока свежести
- **WHEN** срок свежести показанного Portfolio истёк и началось фоновое
обновление данных того же Brokerage Account
- **THEN** клиент сохраняет прежние валютные итоги и время их успешного
получения, показывает неблокирующую индикацию обновления и не заменяет
доступный состав и сводку skeleton-состоянием
#### Scenario: Ошибка обновления после успешного ответа
- **WHEN** клиент уже показывает Client Cached Snapshot Portfolio,
а обновление этих же данных завершается ошибкой
- **THEN** клиент сохраняет данные, суммы и время прежнего снимка,
показывает неблокирующее предупреждение и действие повтора;
ошибка не делает сохранённый снимок свежим и не изменяет `isStale`
#### Scenario: Ошибка клиентского обновления при server-side stale
- **WHEN** клиент показывает Portfolio с `isStale: true`, а фоновое
обновление этого же Portfolio завершается ошибкой
- **THEN** прежние суммы и время остаются видимыми одновременно
с нейтральным server-side предупреждением, отдельным предупреждением
об ошибке клиентского обновления и его действием повтора
#### Scenario: Повторное обновление успешно
- **WHEN** после ошибки фонового обновления User запускает повторную
попытку и получает успешный Portfolio Snapshot
- **THEN** до ответа прежние суммы и время остаются видимыми, затем
клиент показывает значения и `lastSuccessfulAt` успешного ответа,
убирает предупреждение об ошибке клиентского обновления и показывает
server-side предупреждение только в соответствии с новым `isStale`
#### Scenario: Штатный пустой Portfolio на клиенте
- **WHEN** успешно полученный Portfolio содержит пустые `positions`
и `cashBalances`
- **THEN** клиент показывает существующее empty-состояние без валютных
карточек с вымышленными нулевыми итогами
@@ -0,0 +1,32 @@
## 1. Frontend: компактная сводка и сохранение состояний
Основа — утверждённый [Design](design.md), включая пакет `a1-symbols-r1`
и все восемь PNG из его Materials. Перед первой Node-командой активировать
frontend `.nvmrc` через `nvm use --silent`. Зависимости устанавливать из lockfile
через `npm ci`; новую инфраструктуру и зависимости не добавлять.
- [x] 1.1 [frontend] Открыть существующий `formatDecimal` через публичный `shared/lib` и использовать его в сводке с локальными знаками ₽/$/€, ISO-fallback и доступными названиями валют из i18n, сохранив поведение общего `formatMoney`.
- **Covers:** `portfolio-view` — `Обозначение и доступная идентичность валюты в сводке`; `portfolio-view` — `Полные точные значения и смысл валютной сводки`
- **Verification:** Scoped Vitest через `npm run test:run -- src/shared/lib/display.test.ts src/pages/account`: полные обычные и длинные decimal strings из Delta Specs, дробные нули, отрицательные значения и `-0.5`; видимые знаки и доступные полные названия RUB/USD/EUR; раздельные USD/CAD и неизвестный ISO; прежние результаты `formatMoney` вне сводки. Дополнить поведенческие тесты на существующей presentation boundary без копии алгоритма formatter в assertions.
- [x] 1.2 [frontend] Реализовать A1 в `PortfolioTotals` и page-local CSS: отдельные карточки, badge и сумма в строке при вместимости, responsive сетка и полный перенос длинной суммы под метку; сохранить исходные membership, порядок и смысл итогов.
- **Covers:** `portfolio-view` — `Компактная сводка валютных итогов Portfolio`; `portfolio-view` — `Обозначение и доступная идентичность валюты в сводке`; `portfolio-view` — `Полные точные значения и смысл валютной сводки`
- **Verification:** `npm run test:run -- src/pages/account`: одна/три/больше валют, отсутствие фиктивных карточек, отсутствие повторного добавления Cash Balance или общего межвалютного итога; при неизвестной стоимости позиции показываются только переданные суммы, без обещания полной оценки. Проверить семантическую связь названия с полным значением и отсутствие tab stop у карточки. Реальный reflow проверяется в 1.4, а не выводится из jsdom.
- [x] 1.3 [frontend] Заменить область сводки в page-owned initial skeleton тремя декоративными A1 placeholders и дополнить существующие account-page regression checks для сохранённых данных и состояний, не меняя query/VM policy.
- **Covers:** `portfolio-view` — `Состояния и ошибки Portfolio на клиенте`; `portfolio-view` — `Полные точные значения и смысл валютной сводки`
- **Verification:** `npm run test:run -- src/pages/account src/entities/portfolio`: initial busy label без сумм и фокусируемых shapes; refetch → error → retry → success сохраняет прежние суммы и UTC `lastSuccessfulAt` до успешного ответа; server stale отдельно и вместе с client error; `404` с/без Client Cached Snapshot; штатные действия ошибок подключения/токена/временной и неизвестной ошибки; empty по positions/cashBalances и непустой состав без totals. Переиспользовать имеющиеся assertions cache policy: 30 секунд, без automatic retry/polling/focus refetch; добавлять только недостающее покрытие затронутых инвариантов.
- [x] 1.4 [frontend] Проверить реализованный пакет в браузере, исправить расхождения в пределах Design и выполнить canonical frontend gate.
- **Covers:** `portfolio-view` — `Компактная сводка валютных итогов Portfolio`; `portfolio-view` — `Обозначение и доступная идентичность валюты в сводке`; `portfolio-view` — `Полные точные значения и смысл валютной сводки`; `portfolio-view` — `Состояния и ошибки Portfolio на клиенте`
- **Verification:** В существующей локальной витрине/приложении сравнить сводку и loading с `a1-symbols-r1` на 320, 390 и 1440 px и по обе стороны 48/62 em; использовать синтетические обычные, длинные, отрицательные значения, одну/три/дополнительные валюты и zoom 200%. Проверить все цифры и знаки, получение полной ширины под меткой, отсутствие обрезания и горизонтального overflow сводки (`scrollWidth <= clientWidth` для сводки/карточек), меньшую высоту обычных одной/трёх валют относительно прежнего UI при одинаковых данных, ширине, шрифте и масштабе. Проверить accessibility tree, клавиатурный retry, initial/refetch/error/stale и неизменность соседних sections. Сохранить observed measurements/screenshots как implementation evidence, не как новую revision Design. Затем после lockfile install выполнить `npm run verify` из frontend; runtime-openapi не требуется по Design.
## 2. Документация и canonical пакет
- [x] 2.1 [docs] После проверенной frontend реализации обновить `features/portfolio-view.md` описанием фактической компактной сводки, валютных знаков, точных переносимых сумм и ограничений оценки; сохранить согласованное описание состояний.
- **Covers:** `portfolio-view` — `Компактная сводка валютных итогов Portfolio`; `portfolio-view` — `Обозначение и доступная идентичность валюты в сводке`; `portfolio-view` — `Полные точные значения и смысл валютной сводки`; `portfolio-view` — `Состояния и ошибки Portfolio на клиенте`
- **Verification:** Scoped review соответствия документа реализованному UI и применимых локальных ссылок; из docs после `nvm use --silent` и `npm ci` выполнить `npm run verify`. Не переносить planning или будущие verification results в as-built документацию.
- [x] 2.2 [specs] Подготовить к поставке полный canonical change package: утверждённые planning artifacts, восемь Materials `a1-symbols-r1` и фактические отметки завершения implementation Tasks; сохранить неизменными approval-bound содержимое и относительную структуру пакета.
- **Covers:** `N/A` — задача не вводит отдельного пользовательского поведения; specs владеет поставкой уже подготовленного normative/UI пакета и checklist progress согласно Delivery Map. Main-spec sync и archive остаются последующей Finalization.
- **Verification:** Из specs под активированным frontend runtime выполнить `openspec schema validate wealth-ledger-sdd --verbose`, `openspec validate compact-portfolio-totals --type change --strict --store wealth-ledger-specs` и `openspec validate --specs --strict --store wealth-ledger-specs`. Сверить полный native Delta membership, prerequisite approval fingerprints и Tasks fingerprint с нормализацией только checklist progress; проверить все пути/SHA-256 материалов из Design. Не создавать независимый PASS, Completion Evidence или archive в этой task.
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-09-09
@@ -0,0 +1,147 @@
# Completion Evidence: simplify-income-page
## Outcome and authority
Finalization consumed independent **PASS** from Validation task
`01a085bd-2f84-7801-a9c1-d35abc346df1`, record
`.agents/sdd/changes/simplify-income-page/005-validation-2.md` (workspace-relative).
All nine implementation Tasks 1.1–1.4 and 2.1–2.5 are complete.
Canonical store: `wealth-ledger-specs`.
The owner authorized separate Validation/Finalization tasks, delivery commits and
archive in task `01a085af-72e7-78d3-b8a1-bfda579191a6`: «все ок, давай завершать».
This authorization supersedes the earlier request to leave delivery uncommitted.
No push or deployment is part of completion.
## Actual Delivery Map and existing delivery commits
| Area | Status and delivered result | Delivery commit |
| --- | --- | --- |
| frontend | affected: calendar draft/apply/reset form, flat individual payment cards, states and responsive fixes; income-owned trend/forecast consumers removed; regression tests updated | `26f229ba3e0f8335dde297bf6c4575f7014e9f28` |
| docs | affected: as-built calendar filters, flat payments, removed trend/forecast UI and unchanged analytics API | `7166ee51d181b04f3892ff6757a56b578ee9df64` |
| specs | affected: approved Proposal, Delta Specs, inline Design r2 and completed Tasks | `4c905c143368d799c142dbbce3a97d74108d6d28` |
| backend | N/A: existing endpoint, buckets, monetary calculations, events and persisted data unchanged; working tree clean | N/A |
`runtime-openapi`: N/A because neither API side changes; frontend continues using
the existing typed response and shared Future Cash Events remain available.
No empty backend commit or contract gate was required.
Delivery commits existed before this evidence was created. After committing,
re-expansion of all reviewed roots and explicit paths still matched the independent
PASS exactly, including raw content and modes; commit hooks did not alter inputs.
The subsequent evidence/archive/sync commit ID is reported outside this file to
avoid a self-reference.
## Verification commands and observed results
Commands below ran under the applicable `.nvmrc` via
`source ~/.nvm/nvm.sh && nvm use --silent`; specs used frontend runtime before
`cd ../specs`. Current Finalization repeated only `npm ls --depth=0` in frontend
and docs (both exit 0), native status/specs instructions (exit 0), and content
binding checks. Package/lock/runtime inputs match. No install or dependency change
occurred. Unchanged gates and independent review were consumed without repetition.
| cwd | Exact verification command | Observed result |
| --- | --- | --- |
| frontend | `npm run verify` | exit 0: OpenAPI generated comparison, strict TypeScript, Biome, 26 files / 147 tests and coverage, Knip, dependency boundaries, application and catalog builds passed; existing catalog chunk warning nonblocking |
| docs | `npm run verify` | exit 0: Markdown validation and whitespace checks passed; 44 Markdown files checked, 0 issues |
| specs | `openspec validate simplify-income-page --type change --strict --store wealth-ledger-specs` | exit 0: change valid |
| specs | `openspec validate --specs --strict --store wealth-ledger-specs` | exit 0: 25 passed / 0 failed |
| frontend | `npm run test:run -- src/pages/account-income/ui/AccountIncomePage.test.tsx src/pages/account-events/ui/AccountEventsPage.test.tsx` | independent Validation exit 0: 2 files / 35 tests |
| frontend | `node /tmp/validation-005-browser.cjs > /tmp/validation-005-browser.log 2>&1` | independent Validation exit 0; all acceptance assertions pass, zero page errors, contexts closed |
| frontend | `npm run dev -- --host 127.0.0.1 --port 5175 --strictPort` | Vite ready for browser proof; deliberately stopped with Ctrl-C, exit 130 |
| frontend, docs, specs | `git diff --check` | exit 0 in each affected repository |
Exact full command wrappers, cwd, exit codes and logs are retained at workspace
`.agents/sdd/changes/simplify-income-page/evidence-004/commands.json` and
`evidence-005/commands.json` under that same change envelope. Reused gate logs:
`evidence-004/frontend-verify.log`, `docs.log`, `specs-change.log`, `specs-main.log`.
Independent browser harness and observations: `evidence-005/browser.cjs`,
`browser.log`, `browser-result.json` and PNG captures. Detailed independent review:
`evidence-005/review.md`. These are evidence locators, not a competing planning store.
## Independent semantic and acceptance review
PASS; no Critical or Important findings. Review covered approved behavior and
inline package `simplify-income-page-ui-r2`: raw invalid dates retain text, focus,
URL and request count; calendar/keyboard apply, reset, browser navigation and
Moscow date boundary hold. Money signs, repeated IDs, server order, complete
membership validation and per-currency summaries remain intact. Cached snapshot,
empty/error/retry states and independent Future Cash Events navigation pass.
At 320/480/640/960 px all three fixture cards and complete descriptions remain
visible without horizontal overflow; mobile inputs meet 44 px. Actual Chrome
200% zoom measured outer width 960, inner width 480, DPR 2 and viewport height 406;
both calendars fit (bottom 401.5 and 327.70). Screenshots were independently
inspected. The shared Ledger consumer retains its prior clamp behavior.
Limits: browser proof used synthetic intercepted responses and installed Chrome,
not live brokerage data or every browser/zoom setting. A first harness assertion
included hidden inputs; the corrected visible-input selector passed on rerun.
These are not unresolved implementation findings.
## Approval and PASS input binding
Algorithm: `wealth-ledger-input-v1` from context authority. Each entry binds
workspace-relative path, kind, executable mode and SHA-256 payload; files are sorted
by UTF-8 path and NUL-framed. Only Tasks approval normalizes completed task prefixes;
verification includes raw Tasks. No unresolved Git conflicts were present.
Exact reviewed inventory and decision sources are preserved in workspace
`.agents/sdd/changes/simplify-income-page/005-inputs.json` and
`evidence-003/owner-decisions.json`. Inventory file raw SHA-256:
`2f207a30a2eb8f7d8924e5b1b8d98ad7d7f3ac62fbe9b380094741af3c131743`.
| Binding | Fingerprint |
| --- | --- |
| Proposal approval | `sha256:64879ac90bb35ca44922501abbb91d313cdea2d2a47a44bbd7a105d4ff572689` |
| Delta Specs approval | `sha256:d65555952f71f8c9080958adbc954589247ac53041baa3090b5c3da5383379c1` |
| Design approval | `sha256:000c2fcc1fce0e80805aa0b56380f3a616556461e49e6e86a5e721d2caa79490` |
| Tasks approval | `sha256:c99a5e5e5358f30c873b6231119f29d69c6edcf63897e9626d59a967bfd18d3e` |
| Raw planning | `sha256:41a86b7d134a978a19714c0c4d04f3cb51935d21a18db26d188f72794d030915` |
| Frontend gate, 209 entries | `sha256:f799913a03e5513838df53fb9f0f9c25548ec79558238a8b027aa35091e73b12` |
| Docs gate, 63 entries | `sha256:8a9c3bc06dc3a8ddb4f444ecc8a1b5c400804fb822c0cf5cb9d94259695001cf` |
| Specs gate, 347 entries | `sha256:509f2a7112061627fe876039b6bf69c1540d74ba0266e620bcc44eefd559631d` |
| Independent PASS, 620 entries | `sha256:3893b8301a80dfae92676a8bf2c3f78b294007bdd51b230ce35fb09d177d0d93` |
All four canonical artifact identities, native Delta Specs membership and approval
fingerprints match the original owner decisions. Explicit amendment source:
owner task `01a0855f-9b55-7d30-91ca-85636984ca6f`, turn
`01a0857c-1dec-7090-9102-1aeec4ce9c65`, including «Да, отдельная карточка каждой выплаты».
Reviewed roots are the complete frontend/, docs/, specs/ Git tracked and nonignored
untracked candidates from `git ls-files --cached --others --exclude-standard -z`,
union previous inventory entries (deletions remain missing). This covers planning,
relevant main baseline, dependency/config inputs and actual delivery.
Explicit workspace paths: root AGENTS.md; sdd-session, sdd-implementation,
sdd-validation SKILL.md files; sdd-session/references/workflow-protocol.md.
Each gate includes its local instructions. Explicit ignored frontend inputs are
.env, .env.local, .env.development, .env.development.local, .env.production and
.env.production.local; values are never copied into evidence.
Exclusions: .git, node_modules, ignored generated build/coverage/cache outputs,
this change's `.agents/sdd/changes/simplify-income-page/` navigation/evidence,
and its canonical completion-evidence.md. Expected archive/sync/evidence outputs
are verified separately; no unrelated input change is hidden by these exclusions.
## Semantic sync review and completion scope
Current native status and specs instructions resolved exactly one delta:
`specs/brokerage-account-income-analytics/spec.md` inside this change. The main
baseline is `openspec/specs/brokerage-account-income-analytics/spec.md` in the
canonical specs repository. All operation identities matched and all surviving
scenarios in the modified period requirement are retained.
Approved semantic diff: remove requirements for summary/trend visualization,
forecast display and instrument-grouped breakdown; add readable text summary,
income-page forecast exclusion and exact flat payment cards; modify explicit
period selection to include calendar controls, keyboard operation, validation and
no repeated period caption. Preserve the five other requirements and existing
Purpose byte-for-byte. The known old forecast wording in Purpose is explicitly
deferred by approved Design; it does not authorize restoring removed UI.
The package is wholly inline Design revision r2: no attachments or immutable reuse
inputs exist. Native archive must preserve Design and all planning bytes and
relative structure. No Audit ID applies, so no remediation registry mutation is
needed. New archive/main-spec outputs receive native strict checks; completion
record reports the observed archive path and separate completion commit.
@@ -0,0 +1,268 @@
## Context
Изменение ограничено presentation и page orchestration маршрута
`/accounts/:accountId/income`. Текущий `AccountIncomePage` выбирает состояния
через page VM; VM владеет периодом из URL, формой и запросами, а section views
отображают переданные данные. Верхняя форма использует native date inputs;
история событий уже использует установленный `@mantine/dates` DateInput.
Ответ `income-analytics` уже содержит totals, comparisons, instrument breakdown
и все включённые entries. VM соединяет entries с инструментами по непрозрачному
`instrumentKey`, проверяет валюту и receipt count. Денежные значения и порядок
инструментов приходят с backend. Дополнительная подписка income VM на `/events`
обслуживает только удаляемый прогноз. Trend view использует отдельную derived
модель из buckets того же ответа.
Ограничения: FSD `app → pages → entities → shared`, чистые section views,
существующие Mantine theme и i18n, без новой инфраструктуры или зависимостей.
Даты API и URL остаются календарными `YYYY-MM-DD` в Europe/Moscow;
формат отображения поля не меняет их смысл.
## Goals / Non-Goals
**Goals:** использовать существующий календарный контрол, сохранить явную
границу draft/applied period, отобразить полный состав дохода в отдельных
карточках выплат и удалить принадлежащие странице зависимости исчезающих разделов.
Сохранить проверки целостности membership и текущие денежные вычисления.
**Non-Goals:** изменение API, серверных buckets, денежных агрегатов, определения
Instrument, общих Ledger rows, обзора счёта, самостоятельной страницы будущих
событий, общей темы или навигации. Пагинация карточек, виртуализация,
дополнительные фильтры и новый каталог компонентов не вводятся.
## Decisions
### 1. Календарные поля адаптируются на границе page form
Используется Mantine DateInput с `valueFormat="DD.MM.YYYY"` и существующим
русским DatesProvider. Форма остаётся под управлением react-hook-form и Zod в
page VM; form-context/controlled-field adapter подключает DateInput к ней.
Общий date-range widget не создаётся: income требует обязательного периода,
запрета будущих дат и явных ошибок, тогда как Ledger допускает другие фильтры.
Импорта между page slices нет.
VM различает видимый черновик и применённые ISO-даты. Ручной ввод, в том числе
пустой или недопустимый текст, должен попасть в проверяемый черновик. Адаптер
учитывает raw input, календарный выбор и blur; автоматический откат текста
DateInput к последнему допустимому значению на blur отключается (`fixOnBlur=false`).
Парсинг допускает только существующую календарную дату формата поля, без
нормализации вроде 31 февраля в март. Успешный календарный выбор заменяет
черновик поля; валидированный submit переводит обе даты в ISO и обновляет URL.
Последнее внутреннее допустимое значение DateInput не заменяет ошибочный ввод.
Календарь ограничивает начало допустимым окончанием и сегодняшней московской
датой, окончание — допустимым началом и сегодняшней датой. Невалидное соседнее
поле не передаётся в min/max. Ограничения календаря помогают выбору, но Zod
остаётся проверкой обязательности, календарной корректности, будущих дат и
порядка. Для отсутствующей, некорректной или будущей даты ошибка принадлежит
самому полю; при обратном диапазоне — полю «По».
Адаптация календарной даты использует компоненты года, месяца и дня, как в
Ledger, без UTC-преобразования через `toISOString()` пользовательского Date.
«Сегодня» и default period определяются существующим московским правилом VM,
а не часовым поясом устройства. URL остаётся источником применённого периода;
его текущая нормализация, открытие и browser navigation сохраняются.
Сброс очищает ошибки и восстанавливает оба поля даже тогда, когда текущий URL
уже содержит default period, а пользователь изменил только черновик.
Альтернативы: native date inputs не обеспечивают согласованный контрол;
единый range picker меняет привычное взаимодействие двух полей; копирование
всего Ledger filter state переносит неподходящие income семантики. Выбран
существующий DateInput с локальной адаптацией формы.
### 2. Одна карточка соответствует одной выплате
`IncomePayments` отображает один семантический список: каждый Ledger Entry
передаётся существующему `LedgerEntryRow`. Нет внешней Paper-карточки Instrument,
заголовков валютных групп, instrument subtotal/total и receipt count на UI.
Как в списке позиций портфеля, карточки идут общей колонкой; импортов между
page slices и нового row renderer нет. LedgerEntryRow сохраняет дату, описание,
доступную identity, signed Money, характеристику выплаты и fallback изображения.
VM сначала проверяет opaque instrument keys, их непустоту и уникальность,
принадлежность каждой entry к известному instrumentKey, совпадение валюты и
receipt count. Валюта инструмента должна присутствовать среди totalsByCurrency.
Повреждённый ответ идёт целиком в error/retry, без частично заполненного списка.
Клиент не восстанавливает identity формулой и не пересчитывает денежные суммы.
После проверки VM отдаёт плоский список в исходном порядке `analytics.entries`.
В ключ строки входят opaque instrumentKey, operation ID, occurrence внутри
этой группы и logo source. Одинаковые IDs не схлопываются, в том числе между
инструментами. Валюта видна в каждой Money, а общие summary/comparisons по-прежнему
разделены по валютам. Серверный порядок не заменяется клиентской сортировкой.
### 3. Удаление секций завершает их локальное владение
Income page прекращает вызывать `useBrokerageEvents` и готовить forecast model.
Удаляются принадлежащие только этой странице forecast/trend views, derived
trend model и неиспользуемые стили/строки этих секций. Shared event entity,
её запрос и самостоятельная страница будущих событий сохраняются.
Backend продолжает возвращать buckets по прежнему контракту; frontend больше
не строит из них presentation для income page. Это позволяет ограничить
изменение потребителем без миграции transport types и соседних consumers.
Удаление полей API ради размера ответа — отдельное изменение контракта,
не необходимое для этого решения.
### 4. Границы состояний и доказательства остаются у страницы
Top-level loading/error и loaded composition остаются в существующей
container/VM/view структуре. Skeleton отражает новые регионы. Пустой результат
использует StateView; ошибка запроса или membership — существующий error/retry.
При фоновой загрузке того же периода валидный Client Cached Snapshot сохраняется
с неблокирующим progress, а после ошибки — с локальным предупреждением и retry
согласно frontend UI conventions. При отсутствии данных нового query используется loading, без старого результата.
Отдельной подписи применённого периода под формой нет; draft/applied boundary
сохраняется и видимые суммы меняются только после применения.
Проверка опирается на существующие page tests и MSW и защищает период и состав
денежных данных: draft/apply/reset/URL, недопустимый ручной ввод, московскую границу дня,
несколько валют, отрицательные и повторяющиеся выплаты, повреждённый membership
и retry. Удаляемые chart/forecast expectations заменяются наблюдаемым отсутствием
секций и запроса events от income page. Сохраняемые денежные/membership assertions
не удаляются вместе с Accordion. Адаптивность и keyboard flow оцениваются по
пакету ниже; новая test/browser инфраструктура для этого не требуется.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — income page: календарные фильтры,
плоский список выплат, последовательность секций и loading representation.
Общая оболочка, обзор счёта и самостоятельные «Будущие события» вне scope.
**Package revision:** `simplify-income-page-ui-r2`.
Пакет полностью inline; дополнительных attachments, external-only макетов
и immutable reuse inputs нет. Revision r2 отражает уточнение владельца:
убрать подпись периода, заголовки валют и внешние карточки Instrument,
переименовать раздел в «Выплаты», показывать отдельную карточку каждой выплаты.
**Screens, states and interactions:**
Loaded: существующая ширина content до 960 px, Stack gap `lg`, текущая светлая
Mantine theme. h1 страницы; h2 итогов, сравнений и «Выплаты». Нет h3/h4 групп
валют или инструментов. Суммы доступны текстом, не только цветом.
```text
Пассивный доход
┌ Период ──────────────────────────────────────────┐
│ С По │
│ [ДД.ММ.ГГГГ] [ДД.ММ.ГГГГ] │
│ [Применить] [Сбросить] │
└──────────────────────────────────────────────────┘
Получено купонами и дивидендами
[Итого в валюте · Купоны · Дивиденды]
[Остальные валюты отдельными карточками]
Налоговые операции не включены в этот результат.
Сравнения
[Предыдущий период · значения и разницы]
[Годом ранее · значения] или текст недоступности
Выплаты
[Ledger Entry: описание, дата, identity, signed Money]
[Следующая Ledger Entry, отдельная карточка]
```
Форма — одна Paper с border, radius `md`, padding `md`, видимым «Период».
Подписанные DateInput «С» и «По» имеют placeholder `ДД.ММ.ГГГГ`, штатный
календарь по focus/click, `valueFormat="DD.MM.YYYY"`, strict raw validation и
`fixOnBlur=false`. Действия отдельной строкой: «Применить» filled, «Сбросить»
default. Под формой нет повторяющей даты подписи. Неприменённый черновик не
меняет аналитику; ограничения и московское правило описаны в Decision 1.
«Выплаты» — один `ul`, одна LedgerEntryRow-карточка на `li`, gap `sm`.
Не добавляются внешние instrument cards, currency headings, instrument totals,
subtotals/count, chevron, раскрытие или внутренние scroll-панели. Метаданные и
logo fallback принадлежат существующему LedgerEntryRow. Все строки сразу видимы;
денежные значения не обрезаются, при нехватке ширины переносятся.
| Состояние | Представление |
| --- | --- |
| Initial loading / новый период без cached data | Busy skeleton повторяет heading, форму, summary/comparisons и отдельные строки выплат; shapes без фокуса, shell сохраняется. |
| Loaded | Итоги, сравнения и плоский список всех выплат; валюты указаны в Money, summary/comparisons не смешивают их. |
| Изменён draft | Поля отражают ввод; суммы, сравнения и выплаты сохраняют последний применённый результат. |
| Invalid draft | Ошибка под соответствующим полем, фокус на первом ошибочном поле, исходный текст и URL сохраняются, запроса нет. |
| Apply | Click или Enter проверяет raw draft; только успех меняет URL/query, повторный submit во время обработки блокируется. |
| Reset | Немедленно восстанавливает обе даты текущего московского месяца, очищает ошибки и синхронизирует URL даже при изменённом только draft. |
| Empty | Фильтры, «Купоны и дивиденды не найдены» / «За выбранный период подходящих исполненных выплат нет.», налоговое пояснение, сравнения; «Выплаты» без фиктивных карточек. |
| Error без результата / повреждённый membership | StateView «Не удалось рассчитать пассивный доход», пояснение, «Повторить» текущий applied period; частичные данные скрыты. |
| Фоновое обновление того же периода | Cached snapshot и draft сохраняются; progress не блокирует; при ошибке предупреждение с retry. |
| Нет year comparison | «Сравнение с годом ранее недоступно.», без вымышленных нулей. |
| Отрицательные суммы / неполная metadata | Исходный знак, доступная identity и существующий logo fallback; запись не исключается. |
Во всех состояниях отсутствуют trend, buckets UI, forecast и его состояния.
Keyboard order: «С» → «По» → «Применить» → «Сбросить»; Escape закрывает календарь.
Штатные доступные controls Mantine и focus indicator сохранены. Ошибки связаны
с inputs; суммы и неинтерактивные строки не получают tabIndex. Все продуктовые
подписи принадлежат русскому i18n.
**Responsive behavior:**
| Viewport | Форма | Выплаты |
| --- | --- | --- |
| От `48em` | Два равных поля, отдельная строка кнопок по содержимому. | Одна колонка LedgerEntryRow; identity и сумма используют существующий layout. |
| `36em`–`48em` | Поля рядом, кнопки переносятся при необходимости. | Одна колонка, текст и суммы переносятся без обрезки. |
| Менее `36em` | Поля и кнопки вертикально на всю ширину, touch target не менее 44 px. | Существующий mobile row: сумма под identity. |
На 320 px и при увеличенном масштабе страница не требует горизонтального scroll;
popover остаётся в viewport. Высота определяется данными, прокрутка общая,
пагинация, виртуализация и ограничения количества строк не вводятся.
**Reused source and differences:** immutable visual reuse `N/A`.
DateInput истории, LedgerEntryRow, StateView, LoadingSkeleton и theme —
implementation references. Поля income обязательны и запрещают будущие даты;
список выплат плоский. Код страницы портфеля не копируется между slices.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Calendar form adapter, плоский список карточек выплат, composition/states и относящиеся к ним проверки; удаление только income-owned trend/forecast consumer code. |
| backend | `N/A` | Текущий income-analytics уже возвращает totals/comparisons, отсортированные instruments и полные entries с instrumentKey. Endpoint, buckets, денежные расчёты, events и хранение не меняются. |
| specs | `affected` | Canonical planning и последующая синхронизация согласованных delta requirements в capability brokerage-account-income-analytics по lifecycle. |
| docs | `affected` | После реализации актуализируется as-built описание пассивного дохода: календарные фильтры, отдельные карточки выплат, отсутствие подписи периода и trend/forecast UI и использование прежнего analytics response. |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `N/A` | API contract не меняется ни на одной стороне; frontend потребляет прежний типизированный ответ и прекращает отдельный вызов events только со страницы дохода. Snapshot/types не требуют регенерации. |
## Risks / Trade-offs
- Открытый список увеличивает высоту и число одновременно отрисованных строк.
Это ожидаемая цена согласованного полного состава выплат. Используется один
проход группировки в VM и обычный document flow; лимиты, скрытие строк или
виртуализация не подменяют согласованное поведение.
- Raw input DateInput, permissive calendar parsing и timezone conversion могут
незаметно подменить период. Локальный form adapter и строгая проверка видимого
черновика защищают эту границу; одних minDate/maxDate недостаточно.
- Замена Accordion может потерять повторяющиеся выплаты или ошибочные membership
guards при упрощении VM. Сохраняются исходный порядок, occurrence identity и
существующий отказ от повреждённого результата.
- Удаление forecast UI должно удалить и его query subscription; shared events
остаётся нужен самостоятельной странице и не удаляется вместе с consumer.
- В текущем main Purpose осталось упоминание отдельного ближайшего прогноза.
Согласованная delta явно удаляет требование его показа на income page.
Это известная устаревающая формулировка Purpose, не разрешение вернуть UI:
native sync сохраняет existing Purpose и незатронутые requirements. В этом
change Purpose не переписывается; его последующая редактура требует отдельного
разрешённого изменения. Упоминания buckets в сохраняемых серверных инвариантах
совместимы с неизменным API и не требуют их визуализации.
## Migration Plan
Поставка выполняется без серверной или data migration. Сначала обновляется
согласованная frontend composition и её адаптация формы в рамках существующих
границ; после удаления consumers очищаются их локальные неиспользуемые части.
Сохраняемые сценарии периода и membership проходят проверку вместе с новой
компоновкой. Затем docs отражает фактически поставленный поток. Синхронизация
main specs и archive остаются последующими canonical completion mechanics.
Предварительный backend rollout, изменение environment и установка новых
зависимостей не нужны. API остаётся совместимым со старым и новым frontend.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `N/A` |
| Reason | Обратимое read-only изменение frontend UI; auth/security, public API, persistent data и денежные вычисления не меняются. Возврат прежней frontend версии восстанавливает прежнее отображение на том же backend без преобразования или удаления данных. |
@@ -0,0 +1,43 @@
## Why
Страница «Пассивный доход» перегружена ненужными пользователю разделами
«Динамика» и «Ближайший прогноз». Выбор периода отличается от истории событий
портфеля, а просмотр выплат требует раскрывать аккордеоны и проходить лишние уровни группировки.
Нужно упростить страницу согласно запросу владельца продукта.
## What Changes
- Переработать верхний блок фильтров: выбирать начальную и конечную даты
календарным контролом с тем же взаимодействием и форматом `ДД.ММ.ГГГГ`,
что в истории событий портфеля. Сохранить явное применение, сброс периода,
проверку допустимости дат и восстановление применённого периода из URL.
Не дублировать даты отдельной подписью под фильтрами.
- Убрать со страницы блок «Динамика».
- Заменить «По инструментам» разделом «Выплаты»: одна сразу видимая карточка
на каждую выплату, без внешних блоков инструментов, их итогов и количества
выплат и без отдельных заголовков валют.
- Убрать со страницы блок «Ближайший прогноз».
Ожидаемый результат: пользователь выбирает период привычным способом и сразу
видит итоги, сравнения и все выплаты, образующие полученный доход.
Non-goals: изменение расчётов дохода, правил отбора выплат, разделения валют,
сравнений периодов, обзора счёта или самостоятельной страницы будущих событий.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `specs/brokerage-account-income-analytics/spec.md`: унифицировать выбор
периода, убрать требования показывать динамику и ближайший прогноз на
странице дохода, представить каждую выплату отдельной карточкой без группировочных блоков.
## Impact
Изменение затрагивает пользователей страницы дохода выбранного брокерского
счёта, её интерфейс, нормативное описание поведения и пользовательскую
документацию. Источники данных и денежные расчёты сохраняются.
@@ -0,0 +1,130 @@
## REMOVED Requirements
### Requirement: Analytics summary and trend remain readable per currency
**Reason**: По согласованному Proposal блок «Динамика» удаляется вместе со сценариями дневной, недельной и месячной группировки и переключения валюты графика. Сохранение этих сценариев требовало бы показывать удаляемый блок.
**Migration**: Требования к читаемым итогам, разделению валют и объяснению налоговой границы перенесены в `Analytics summary remains readable per currency`. Удаление визуализации не меняет денежные расчёты.
### Requirement: Forecast is separate from realized income
**Reason**: По согласованному Proposal со страницы удаляется весь блок «Ближайший прогноз» вместе со сценариями отображения его сумм, событий и локальной ошибки.
**Migration**: `Income page excludes upcoming forecasts` сохраняет отделение будущих событий от полученного дохода, независимость аналитики от доступности прогноза и доступность самостоятельной страницы «Будущие события».
### Requirement: Instrument breakdown is traceable to exact Ledger Entries
**Reason**: По уточнению владельца удаляются внешние карточки Instrument с subtotals, combined total и receipt count, а также валютные группы. Прежние scenarios группировочного breakdown больше не описывают требуемый UI.
**Migration**: `Income payments remain traceable to exact Ledger Entries` заменяет grouping UI плоским разделом «Выплаты», сохраняя точный состав entries, signed Money, повторяющиеся IDs и внутренние membership guards.
## MODIFIED Requirements
### Requirement: User explicitly applies an analytics period represented in the URL
Страница выбранного Brokerage Account «Пассивный доход» MUST позволять User редактировать черновик `from` и `to`, не изменяя видимую аналитику до явного применения. Верхний блок фильтров MUST предоставлять два подписанных календарных поля начала и окончания периода с тем же взаимодействием выбора даты, что в истории событий портфеля, и отображением дат в формате `ДД.ММ.ГГГГ`. Поля и действия применения и сброса MUST быть доступны с клавиатуры. Явное применение MUST заменить применённый период, загрузить соответствующий результат и представить применённые даты в URL страницы. Недопустимый черновик MUST показывать ошибку соответствующего поля и MUST NOT заменять последний применённый период. Сброс MUST немедленно вернуть применённый период к текущему месяцу по сегодняшний день и синхронизировать URL. Перезагрузка или открытие URL с допустимым периодом MUST восстановить тот же применённый период. Страница MUST NOT дублировать период отдельной подписью под блоком фильтров. Действие перехода на страницу MUST быть доступно из выбранного Brokerage Account, а не из глобальной навигации.
#### Scenario: Черновик периода не применён
- **WHEN** User изменяет черновик `from` или `to`, но не применяет его
- **THEN** видимые итоги, сравнения и карточки выплат остаются основанными на последнем применённом периоде
#### Scenario: Выбор дат в верхнем блоке
- **WHEN** User выбирает начало и окончание периода через календарные поля
- **THEN** выбранные даты отображаются в подписанных полях в формате `ДД.ММ.ГГГГ`, а изменение остаётся черновиком до явного применения
#### Scenario: Управление фильтрами с клавиатуры
- **WHEN** User управляет верхним блоком фильтров с клавиатуры
- **THEN** он может последовательно перейти к обоим полям дат, изменить период и выполнить применение или сброс без мыши
#### Scenario: Применение периода
- **WHEN** User явно применяет допустимый черновик периода
- **THEN** страница загружает аналитику этого периода и отражает применённые `from` и `to` в URL
#### Scenario: Недопустимый черновик периода
- **WHEN** User пытается применить черновик с отсутствующей, некорректной или будущей датой либо с `from` после `to`
- **THEN** страница показывает ошибку соответствующего поля и сохраняет последний применённый период, его URL и видимую аналитику
#### Scenario: Восстановление применённого периода из URL
- **WHEN** User открывает URL страницы с допустимыми `from` и `to`
- **THEN** страница показывает аналитику за указанный применённый период
#### Scenario: Сброс периода
- **WHEN** User сбрасывает пользовательский период
- **THEN** страница немедленно применяет период от первого дня текущего месяца по Europe/Moscow до сегодняшнего дня и синхронизирует URL
## ADDED Requirements
### Requirement: Income payments remain traceable to exact Ledger Entries
В разделе «Выплаты» страница MUST показывать единый список карточек точных исполненных Ledger Entries применённого периода в исходном серверном порядке entries. Каждая выплата MUST иметь собственную сразу видимую карточку с описанием, датой, доступной identity Instrument и signed Money в исходной валюте. Страница MUST NOT добавлять внешние группировочные карточки или заголовки по Instrument либо валюте, subtotals инструмента или количество выплат инструмента. Список MUST сохранять отрицательные корректировки и повторяющиеся operation IDs как отдельные записи и MUST NOT включать записи вне аналитического итога. Отказ от группировочного UI MUST NOT отменять проверки принадлежности entries к instrumentKey, валюты и receipt count ответа или приводить к пересчёту денежных итогов на клиенте.
#### Scenario: Каждая выплата показана отдельной карточкой
- **WHEN** выбранный период содержит несколько включённых Ledger Entries одного или разных Instrument
- **THEN** раздел «Выплаты» сразу показывает отдельную карточку каждой записи в исходном порядке без внешних группировочных блоков, раскрытия, итогов инструмента и receipt count на UI
#### Scenario: Список воспроизводит точный состав дохода
- **WHEN** User просматривает загруженный раздел «Выплаты»
- **THEN** он видит все и только включённые coupon и dividend Ledger Entries применённого периода с датой, описанием, доступной identity и исходной суммой
#### Scenario: Отрицательная корректировка и повторяющиеся IDs сохраняются
- **WHEN** доход содержит отрицательную корректировку либо несколько включённых entries с одинаковым operation ID
- **THEN** каждая запись остаётся отдельной карточкой, а корректировка отображается с исходным знаком
#### Scenario: Валюта видна в сумме каждой выплаты
- **WHEN** подходящие выплаты одного Instrument представлены в нескольких исходных валютах
- **THEN** каждая карточка показывает свою Money с исходной валютой, без отдельного заголовка валютной группы или объединённого мультивалютного итога
### Requirement: Analytics summary remains readable per currency
Страница MUST обозначать фактический результат как «Получено купонами и дивидендами» и для каждой исходной валюты показывать total, coupon subtotal и dividend subtotal выбранного периода. Она MUST объяснять, что tax operations не включены, не называя фактический результат gross, net, суммой до налогов, суммой после налогов или налоговым расчётом. Значения MUST оставаться доступными как читаемый текст, не только как графика, цвет или положение. Страница MUST NOT показывать блок «Динамика», его график, переключатель валюты графика или текстовый список временных buckets независимо от длительности выбранного периода. Удаление блока MUST NOT изменять итоги или сравнения периодов.
#### Scenario: Итоги дохода доступны как текст
- **WHEN** выбранный период содержит исполненные купонные или дивидендные выплаты
- **THEN** страница показывает читаемые total, coupon subtotal и dividend subtotal с обозначением фактически полученного дохода и объяснением исключения tax operations
#### Scenario: Итоги нескольких валют остаются разделёнными
- **WHEN** результат содержит подходящие выплаты в нескольких исходных валютах
- **THEN** страница показывает отдельные читаемые итоги каждой валюты без конвертации и общего итога разных валют
#### Scenario: Динамика отсутствует для периода любой длительности
- **WHEN** User открывает аналитику или применяет допустимый период любой длительности
- **THEN** страница показывает итоги и существующие сравнения, но не показывает блок «Динамика», график, его переключатель валюты или текстовый список временных buckets
### Requirement: Income page excludes upcoming forecasts
Страница «Пассивный доход» MUST NOT показывать блок «Ближайший прогноз», его суммы, список будущих выплат, состояния загрузки, пустоты или ошибки. Forecast values MUST NOT входить в realized total, comparisons, trend buckets или instrument breakdown. Доступность данных Future Cash Events MUST NOT влиять на отображение успешно загруженной realized analytics. Существующая самостоятельная страница «Будущие события» и её доступность из выбранного Brokerage Account MUST сохраняться.
#### Scenario: Известные будущие выплаты не появляются на странице дохода
- **WHEN** для выбранного Brokerage Account известны будущие coupon или dividend события с суммой или без неё либо maturity или offer события
- **THEN** страница дохода не показывает блок «Ближайший прогноз» и не включает эти будущие события в фактически полученный доход
#### Scenario: Изменение исторического периода не возвращает прогноз
- **WHEN** User применяет допустимый исторический период или сбрасывает фильтры
- **THEN** страница показывает соответствующую realized analytics без блока «Ближайший прогноз»
#### Scenario: Данные будущих событий недоступны
- **WHEN** realized analytics успешно загружена, а данные Future Cash Events недоступны
- **THEN** итоги, сравнения и карточки выплат остаются доступными, а страница дохода не показывает состояние ошибки или загрузки прогноза
#### Scenario: Самостоятельная страница будущих событий сохраняется
- **WHEN** User переходит из выбранного Brokerage Account на существующую страницу «Будущие события»
- **THEN** эта страница остаётся доступной с прежним поведением независимо от отсутствия прогноза на странице дохода
@@ -0,0 +1,32 @@
## 1. Frontend behavior and presentation
- [x] 1.1 [frontend] Перевести форму периода income page на управляемые Mantine DateInput, сохранив отдельные draft и applied значения, строгую проверку видимого ввода, московскую границу сегодняшнего дня, явное применение, немедленный сброс и восстановление из URL.
- **Covers:** `brokerage-account-income-analytics` — `User explicitly applies an analytics period represented in the URL`
- **Verification:** Page UI tests подтверждают формат `ДД.ММ.ГГГГ`, календарный и клавиатурный ввод, отсутствие запроса и сохранение результата/URL при invalid draft, применение допустимого периода, reset при изменённом draft и восстановление applied period из URL.
- [x] 1.2 [frontend] Заменить Accordion breakdown плоским разделом «Выплаты» с отдельной LedgerEntryRow-карточкой каждой entry, без внешних instrument cards, их итогов/count и заголовков валют; сохранить исходный порядок entries, opaque membership guards, signed payments и устойчивые keys повторяющихся entries.
- **Covers:** `brokerage-account-income-analytics` — `Instrument breakdown is traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`
- **Verification:** Page/component tests подтверждают одну карточку на entry в исходном порядке, отсутствие instrument/currency groups и instrument totals/count; несколько валют, отрицательные корректировки, повторяющиеся IDs и повреждённый membership сохраняют согласованные outcomes без disclosure action.
- [x] 1.3 [frontend] Удалить из income page trend и forecast composition, derived models, events query subscription и ставшие неиспользуемыми локальные views/styles/i18n, сохранив текстовые totals, currency separation, comparisons и независимую страницу Future Cash Events.
- **Covers:** `brokerage-account-income-analytics` — `Analytics summary and trend remain readable per currency`; `brokerage-account-income-analytics` — `Forecast is separate from realized income`; `brokerage-account-income-analytics` — `Analytics summary remains readable per currency`; `brokerage-account-income-analytics` — `Income page excludes upcoming forecasts`
- **Verification:** Page/route tests подтверждают текстовые итоги и сравнения без «Динамики» и «Ближайшего прогноза», отсутствие запроса events со страницы income, устойчивость realized analytics при недоступности events API и прежнюю доступность самостоятельной страницы «Будущие события».
- [x] 1.4 [frontend] Привести loaded, loading, empty, error и cached-snapshot presentation к inline UI/UX package revision `simplify-income-page-ui-r2`, включая отсутствие отдельной подписи периода, семантическую иерархию, keyboard focus, field errors и responsive layout фильтров и отдельных карточек выплат.
- **Covers:** `brokerage-account-income-analytics` — `User explicitly applies an analytics period represented in the URL`; `brokerage-account-income-analytics` — `Instrument breakdown is traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Analytics summary remains readable per currency`; `brokerage-account-income-analytics` — `Income page excludes upcoming forecasts`
- **Verification:** UI tests проверяют доступные labels/errors, порядок действий и state selection; scoped review на ширинах 320 px, менее `36em`, `36em`–`48em` и от `48em` подтверждает отсутствие горизонтального scroll, перенос длинных названий и сумм, 44 px touch targets и соответствие package revision.
## 2. Affected repository delivery
- [x] 2.1 [frontend] Выполнить canonical frontend gate после завершения income page delivery.
- **Covers:** `N/A` — non-mutating gate проверяет frontend implementation уже покрытых Requirements и UI/UX package, не создавая самостоятельного observable behavior.
- **Verification:** Под Node 22 выполнить `npm run verify` в `frontend`.
- [x] 2.2 [docs] Обновить as-built описание пассивного дохода: календарные draft/applied фильтры, отдельная карточка каждой выплаты без instrument/currency groups и подписи периода, сохранённые totals/comparisons и отсутствие trend/forecast UI при неизменном income-analytics API.
- **Covers:** `brokerage-account-income-analytics` — `Analytics summary and trend remain readable per currency`; `brokerage-account-income-analytics` — `Forecast is separate from realized income`; `brokerage-account-income-analytics` — `User explicitly applies an analytics period represented in the URL`; `brokerage-account-income-analytics` — `Instrument breakdown is traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Analytics summary remains readable per currency`; `brokerage-account-income-analytics` — `Income page excludes upcoming forecasts`
- **Verification:** `docs/features/brokerage-account-income-analytics.md` описывает только фактически поставленный flow, сохраняет корректные локальные ссылки и не копирует Proposal, Delta Specs, Design или Tasks.
- [x] 2.3 [docs] Выполнить canonical docs gate после as-built update.
- **Covers:** `N/A` — non-mutating gate проверяет affected docs delivery и ссылочную целостность, не добавляя product behavior.
- **Verification:** Под Node 22 выполнить `npm run verify` в `docs`.
- [x] 2.4 [specs] Сверить завершённые frontend и docs изменения и отмеченные Tasks со всеми operation sections canonical delta, не изменяя approved semantics и сохраняя backend и runtime-openapi как `N/A`.
- **Covers:** `brokerage-account-income-analytics` — `Analytics summary and trend remain readable per currency`; `brokerage-account-income-analytics` — `Forecast is separate from realized income`; `brokerage-account-income-analytics` — `User explicitly applies an analytics period represented in the URL`; `brokerage-account-income-analytics` — `Instrument breakdown is traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`; `brokerage-account-income-analytics` — `Analytics summary remains readable per currency`; `brokerage-account-income-analytics` — `Income page excludes upcoming forecasts`
- **Verification:** Requirement-by-requirement comparison с Proposal, Delta Specs, Design package revision `simplify-income-page-ui-r2` и delivery не оставляет unchecked implementation scope или поведения вне approved artifacts; найденный semantic mismatch маршрутизируется в ответственную planning boundary.
- [x] 2.5 [specs] Выполнить canonical native strict validation change и main specs после обновления task progress.
- **Covers:** `N/A` — structural validation является обязательным gate affected specs repository и не представляет новое пользовательское поведение.
- **Verification:** Под Node 22 выполнить `openspec validate simplify-income-page --type change --strict --store wealth-ledger-specs` и `openspec validate --specs --strict --store wealth-ledger-specs` из `specs`.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-09-12
@@ -0,0 +1,40 @@
# Completion Evidence: add-manual-bank-cards
## Finalization Authority
Change Owner authorized Finalization, canonical sync and archive after a current
independent read-only Validation PASS. The PASS found no Critical or Important
findings after the monthly-summary presentation correction.
## Delivery
| Area | Result | Delivery commit |
| --- | --- | --- |
| frontend | Manual card screens and summaries; Finalization correction shows exact expenses, refunds and net totals with a regression test. | `ff67a76`, `d3ac8c2`, `734c5bd` |
| backend | Bank-card persistence, calculations, API and isolated verification. | `e6f58ef` |
| docs | Cards overview states the three displayed summary totals. | `3a27268` |
| specs | Approved legacy Proposal, Delta Specs, Design, completed Tasks and Finalization evidence; native sync creates canonical behavior spec. | `834f2d1` plus finalization/archive commit |
## Observed Verification
| Area | Command or review | Result |
| --- | --- | --- |
| frontend | `npm run test:run -- src/entities/bank-card/ui/BankSummary/BankSummary.view.test.tsx` | PASS: 1 file, 1 test |
| frontend | `npm run typecheck` | PASS |
| docs | `npm run verify` | PASS |
| specs | `openspec schema validate wealth-ledger-sdd --verbose` | PASS |
| specs | `openspec schema validate wealth-ledger-sdd-v2 --verbose` | PASS |
| specs | `openspec validate add-manual-bank-cards --type change --strict --store wealth-ledger-specs` | PASS |
| specs | `openspec validate --specs --strict --store wealth-ledger-specs` | PASS: 25 specs, 0 failed |
| all affected areas | `git diff --check` | PASS |
## Independent Validation
PASS. The review compared Proposal, all 17 Delta Requirements, Design and
completed Tasks with current backend calculations/DTOs, shared frontend summary
rendering and cards documentation. It verified that `/cards` and `/cards/:id`
share the corrected summary view and found no Critical or Important findings.
Known non-blocking limitation: a plain Prisma fresh migration deploy still needs
`RUST_LOG=info`; the verified migration path and its evidence remain recorded in
the change Tasks.
@@ -0,0 +1,445 @@
## Context
**Revision:** 1. **Status:** согласован пользователем для подготовки Tasks;
реализация не начата.
Основание — [Proposal](proposal.md) и [Delta Specs](specs/manual-bank-cards/spec.md)
с ответами пользователя Q1–Q9. Здесь определён способ реализации; проверяемое
поведение и денежные правила принадлежат Delta Specs.
WealthLedger уже использует React/Mantine, TanStack Query, NestJS,
Prisma и локальную SQLite. Брокерские сведения приходят из T-Bank и временно
кэшируются; пользовательские банковские операции станут долговременными
данными. Их источник и правила доступа отличаются от брокерского журнала.
Подход опирается на существующие границы: [контейнеры](../../../../docs/architecture/containers.md),
[Money](../../../../docs/glossary.md#money), [backend data](../../../../backend/.ai/conventions/backend-data.md)
и [frontend data](../../../../frontend/.ai/conventions/frontend-data.md).
Ни внешний банк, ни новый сервис для ручного учёта не требуются.
## Goals / Non-Goals
**Goals:** один владелец ручного учёта, точные денежные расчёты, согласованные
переводы и возвраты, безопасные исправления и повторы, сохранение истории
между запусками, два экрана в существующей визуальной системе.
**Non-Goals:** новый стек хранения, универсальная бухгалтерская платформа,
двойная запись, event sourcing, серверный кэш банковского учёта, отдельный
изменяемый баланс или конвертация валют. Продуктовые границы перечислены
в Proposal; этот Design их не расширяет.
## Decisions
### 1. Отдельная область ручного банковского учёта
Backend получает один `BankCardsModule` с controller, сервисом сценариев,
DTO и чистыми функциями расчёта. Сервис использует существующий `PrismaService`.
Controller отвечает за транспорт, сервис — за владение данными, связи
операций, проверки и транзакции. Generic repository, интерфейс для одной
реализации и отдельные сервисы на каждый арифметический шаг не нужны.
`JwtAuthGuard` и `request.user.sub` остаются источником локальной идентичности.
Каждая карта, категория и операция выбирается с ограничением по владельцу;
все дополнительные ссылки проверяются в том же сценарии. Неизменяемый
владелец связанных записей не определяется из входного `userId`.
`TbankAccessService` не используется: он устанавливает доступ через подключение
и счета брокера. Ручные карты доступны без T-Bank. Замена или удаление
брокерского подключения не удаляет и не инвалидирует ручную историю;
logout по-прежнему очищает данные сессии в браузере.
### 2. Три сохраняемые модели
| Модель | Данные и ответственность |
| --- | --- |
| `BankCard` | `id`, `userId`, имя, необязательные банк и последние четыре цифры, начальный остаток в копейках, дата начала учёта, `version`, `archivedAt`, неизменяемый fingerprint создания, служебные даты |
| `ExpenseCategory` | `id`, `userId`, название, нормализованный ключ уникальности названия, необязательный неизменяемый `seedKey`, `version` |
| `BankCardOperation` | `id`, `userId`, вид, `cardId`, необязательные `targetCardId`, `categoryId`, `originalExpenseId`, положительная сумма в копейках, дата операции, описание, `version`, `deletedAt`, неизменяемый fingerprint создания, служебные даты |
Новые таблицы ссылаются на существующий `User`. Foreign keys сохраняют
ссылочную целостность; ограничения уникальности защищают идентификаторы
и категории при гонках. Совпадение владельцев, допустимые сочетания полей
и финансовые зависимости проверяются сервисом внутри транзакции.
Удаление карты или категории каскадом не применяется.
При первой карте её создание и начальный набор пользовательских категорий
сохраняются согласованно. `seedKey` позволяет повторно использовать начальные
категории после переименования и удерживает роль категории по умолчанию.
Если первая запись категории создаётся раньше карты, тот же сценарий сначала
обеспечивает начальный набор. Чтение не создаёт данных, повторная инициализация
не возвращает старые названия и не добавляет дубли.
Перевод — одна `BankCardOperation`: `cardId` задаёт источник, `targetCardId`
получателя. Две строки истории являются представлениями одной записи
относительно выбранной карты. Изменение или удаление этой записи затрагивает
оба эффекта; отдельного сохраняемого состояния «половина перевода» нет.
Возврат ссылается на расход через `originalExpenseId`. Его карта проверяется
по исходному расходу, а категория получается через эту связь. Собственная
копия категории в возврате не хранится, поэтому исправление категории покупки
не оставляет возвраты в прежней группе. Вид операции и ссылка возврата
на исходную покупку после создания неизменны.
### 3. Копейки в хранилище, Money на внешней границе
Начальный остаток и суммы операций сохраняются как `BigInt` целых копеек.
Входные денежные значения проверяются по диапазону и точности Delta Specs
до преобразования и записи. Расчёты используют `bigint`; результат не проходит
через `Number`, `parseFloat` или SQLite `SUM` с ограничением 64-битного итога.
Вычисленный баланс может выходить за диапазон отдельного сохраняемого значения.
Публичный контракт остаётся Money `{ currency, value }`, где `value` — точная
decimal string. Ограничение двух знаков относится к новому вводу RUB,
а не к существующим брокерским Money. Frontend использует существующий
[formatMoney](../../../../frontend/src/shared/lib/display.ts) и не складывает суммы.
Уже есть [decimal helpers](../../../../backend/src/tbank/decimal.ts) и
`MoneyDto` в [portfolio.dto.ts](../../../../backend/src/tbank/portfolio.dto.ts).
Общие parsing/formatting и денежный DTO получают общий owner вне `tbank`;
protobuf-преобразование остаётся у интеграции. Существующие потребители
продолжают использовать ту же точную арифметику и тот же публичный Money-контракт.
Новая денежная библиотека и две копии парсера не нужны.
### 4. Баланс и сводка вычисляются из первичных записей
| Вид | Эффект на баланс | Эффект на месячную сводку |
| --- | --- | --- |
| Расход | Минус сумма у `cardId` | Расход по категории и дате покупки |
| Пополнение | Плюс сумма у `cardId` | Не включается |
| Перевод | Минус у источника, плюс у получателя | Не включается у обеих сторон |
| Возврат | Плюс сумма у карты исходного расхода | Возврат по категории покупки и собственной дате |
Backend выбирает минимальные необходимые поля и выполняет точный fold.
Баланс использует начальный остаток и всю неудалённую историю карты; месячная
сводка использует относящиеся к месяцу расходы и возвраты. Начальный остаток
не создаёт синтетическое поступление. Архивная карта остаётся в исторической
сводке «Все карты», чтобы архивирование не переписывало месячные итоги.
Внутри одного ответа связанные первичные данные читаются согласованно.
Если требуется несколько зависимых чтений, они используют одну read-транзакцию;
независимые запросы интерфейса не получают общей транзакции ради внешнего вида.
Frontend после подтверждённых изменений перечитывает связанные представления.
Цена подхода — O(N) просмотр нужной ручной истории при расчёте. Для первой
версии это проще второго сохраняемого баланса, его восстановления и
инвалидации. В реализации у fold будет комментарий `ponytail:` с этим
пределом и переходом к измеренной оптимизации при росте истории. Пагинация
журнала не используется для ограничения финансового расчёта.
Даты ручного учёта сохраняются как проверенные календарные `YYYY-MM-DD`,
а не как фиктивный момент полуночи. Сегодня и текущий месяц определяются
в Europe/Moscow; выбранный месяц — явный параметр. Это сохраняет календарный
смысл введённого дня независимо от часового пояса браузера.
### 5. Транзакции защищают связанные изменения
Проверка владельцев и активности всех старых и новых ссылок, проверка дат,
финансовых зависимостей и запись выполняются в одной DB-транзакции.
Изменение начальной даты карты проверяет всю зависимую историю, включая
переводы, в которых карта является получателем. Сетевых запросов к банкам
внутри транзакции нет.
При создании, изменении или удалении возврата исходный расход участвует
в условном обновлении версии внутри той же транзакции. Проверка оставшейся
доступной к возврату суммы выполняется после получения права на изменение.
При редактировании сумма остальных неудалённых возвратов складывается
с новой суммой текущего: редактируемая запись не учитывается дважды.
Так два возврата и одновременное уменьшение исходного расхода не обходят
общий лимит, а изменение описания полного возврата остаётся допустимым.
Неудачная проверка откатывает всё действие.
Операции, редактирование метаданных и архивирование согласуются средствами
SQLite-транзакций. Конфликт занятой базы или сериализации не превращается
в частичный успех. Для кратковременного DB-конфликта допускается ограниченный
повтор всей транзакции с тем же идентификатором намерения; пользовательский
конфликт версии не повторяется скрыто. In-memory mutex и глобальный
transaction manager не вводятся.
Редактирование и удаление используют `version`: условное обновление по
идентификатору, владельцу и ожидаемой версии. Устаревшая форма получает
конфликт; frontend сохраняет ввод и выполняет повторный GET для получения
актуальной записи, а не ожидает финансовый DTO внутри ответа об ошибке
и не затирает изменение из другой вкладки. Начальный остаток и дата начала также защищены
версией карты. Принцип уже используется в
[AuthService](../../../../backend/src/auth/auth.service.ts) для conditional update;
финансовые правила остаются у нового владельца.
### 6. Повтор создания не создаёт вторую карту или операцию
При открытии формы новой карты или операции клиент создаёт UUID будущей записи
и сохраняет его до определённого результата. Повтор после сетевой ошибки
и общий auth-retry используют тот же UUID. Это идентификатор пользовательского
действия, а не транспортный `X-Request-ID`.
Запись хранит неизменяемый fingerprint нормализованного исходного ввода,
рассчитанный стандартным `node:crypto`. Уникальный идентификатор защищён
ограничением базы. Повтор с тем же владельцем, UUID и fingerprint не применяет
создание второй раз; другой ввод с тем же UUID даёт конфликт.
После проверки сессии и формы входа уже сохранённое намерение разрешается
до проверки изменяемых условий нового создания: архива, текущей даты
и доступного лимита возврата. Полный возврат с потерянным ответом не становится
новым возвратом с нулевым лимитом, а поздний повтор создания архивированной
карты возвращает её актуальное состояние.
При коллизии создания сервис перечитывает результат и проверяет владельца
и fingerprint, не полагаясь только на предварительный lookup.
Редактирование не меняет fingerprint создания. Удаление оставляет tombstone
с идентификатором и fingerprint: запоздалый повтор не воскрешает запись.
Для уже изменённой записи возвращается её актуальное состояние; для удалённой
— понятный безопасный отказ без нового сохранения. После определённого
отказа пользователь может сознательно начать новое действие с новым UUID;
автоматическая смена UUID при неопределённом исходе запрещена.
В обеих моделях fingerprint остаётся неизменным; общего журнала команд
или отдельной таблицы idempotency не требуется.
Удаление операций логическое (`deletedAt`), не полное версионирование или бухгалтерский
журнал аудита. Повтор удаления своей уже удалённой операции безопасен.
Восстановление удалённых операций отдельным пользовательским сценарием
в первую версию не добавляется. Архив карты обратим; для финансовых
исправлений архивную карту сначала возвращают в активный список.
### 7. HTTP и frontend сохраняют существующие границы
Новый API размещается под `/api/v1/bank-cards`. Внутри этой области находятся
ресурсы карт, категорий, операций и месячной сводки. Одно представление
перевода изменяется по идентификатору операции независимо от того, из какой
карты открыта форма. Для выбора исходного расхода возврата доступно чтение
расходов карты за всю историю, без ограничения месяцем открытого журнала.
Runtime DTO валидируют форму, сервис — связанные ограничения. Чужие
и отсутствующие ресурсы не раскрывают финансовых сведений. Ошибки используют
существующий filter и стабильные коды для невалидного ввода, отсутствия
ресурса, архива, конфликта версии, повторного намерения и зависимых возвратов.
Конкретная исполняемая форма ответа принадлежит backend OpenAPI, а не
дублирующей таблице DTO в спецификациях.
Frontend получает типы из нового runtime OpenAPI через существующий
[contract workflow](../../../../docs/development/runtime-openapi-verification.md).
Используются `apiClient`/`apiQueryClient`, session-aware queries и общий auth
middleware. Свой Bearer header, refresh loop или вторую систему запросов
новая область не вводит.
После успешного изменения данных инвалидируются банковские GET-запросы:
список, детали, категории, страницы истории, выбор исходного расхода и
все месячные сводки. Такой scope покрывает обе стороны перевода, изменение
даты между месяцами и перегруппировку связанных возвратов. Брокерские кэши
не затрагиваются. Оптимистический пересчёт денежных сумм не используется;
ошибка mutation сохраняет прежние данные и введённую форму.
Композиции принадлежат `pages/cards` и `pages/card`; каждая финансовая страница
имеет container, VM и presentation views. Общие для обеих страниц banking
queries и представления принадлежат `entities/bank-card`. Только реально
повторяющиеся формы получают общего владельца; универсальный ledger,
новый слой widgets и отдельный feature на каждое поле не нужны.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — новый раздел «Карты», список,
страница карты, формы карты/операций/категорий и месячная сводка.
**Package revision:** 1. Пакет полностью находится в этом Markdown: схемы
композиции, состояния, действия и адаптивность. Отдельные PNG/SVG и внешние
макеты для согласования этой версии не требуются; self-hash не используется.
**Screens, states and interactions:**
| Экран | Состав и основные действия |
| --- | --- |
| `/cards?month=YYYY-MM` | Заголовок «Карты», «Добавить карту», список активных карт, переключатель показа архива, месячная сводка всех карт, «Категории» после появления первой карты |
| `/cards/:cardId?month=YYYY-MM` | Крошки «Карты / Название», баланс, «Добавить операцию», изменение сведений/архив, сводка и журнал выбранного месяца |
| Архивная карта | Явная отметка архива, доступные баланс/история/сводки, действие «Вернуть из архива»; финансовый ввод и исправления недоступны |
```text
Карты [Добавить карту]
[Повседневная 24 800 ₽] [Запасная 10 000 ₽]
Баланс по внесённым операциям [Показать архив]
Расходы по всем картам [Сентябрь 2026]
Потрачено Возвращено После возвратов
8 500 ₽ 1 000 ₽ 7 500 ₽
Продукты … ₽ Кафе … ₽ Такси … ₽
[Категории]
Карты / Повседневная
Повседневная 24 800 ₽ [Добавить операцию]
Баланс по внесённым операциям [Изменить карту]
Расходы за месяц [Сентябрь 2026]
Потрачено / Возвращено / После возвратов
Категории и суммы
Операции
12.09 Расход · Кафе −500 ₽
11.09 Перевод на «Запасная» −2 000 ₽
10.09 Возврат · Покупки +1 000 ₽
[Назад] [Далее]
```
Главная иерархия карты — название, баланс, действие, журнал. Начальные
декоративные изображения банков не загружаются: название и необязательные
последние цифры различают карты без внешнего каталога. Связанные ссылки
перевода объясняют направление; одна карта не имитирует брокерский счёт.
Сводка показывает расходы, возвраты и разницу отдельно. Отрицательный итог
месяца или категории отображается со знаком и поясняется возвратами;
процентный круговой график для таких величин не используется. Баланс
остаётся текущим при смене месяца. Журнал упорядочен по дате операции,
затем времени создания и идентификатору; переходы не обещают неизменяемый
снимок истории между конкурентными изменениями.
| Форма | Ввод и взаимодействие |
| --- | --- |
| Карта | Название, необязательные банк и последние четыре цифры, начальный остаток, дата начала. Пояснение: «Остаток до операций с выбранной даты». Валюта RUB фиксирована |
| Расход | Сумма, дата, предвыбранная категория по умолчанию (изначально «Другое»), необязательное описание |
| Пополнение | Сумма, дата, необязательное описание; без категории расходов |
| Перевод | Карта-источник и другая активная карта-получатель, сумма, дата, описание; одно подтверждение для обеих сторон |
| Возврат | Исходный расход за любой период, дата, сумма не выше доступной к возврату, описание; карта и категория берутся из покупки. При редактировании лимит равен сумме покупки минус остальные неудалённые возвраты |
| Категории | Начальные и свои категории одним списком; добавление и переименование, без вложенности и удаления |
Выбор возврата не ограничивается текущим месяцем: он показывает дату,
описание/категорию покупки, сумму и оставшуюся доступную сумму. Добавление
категории из формы расхода сохраняет уже введённые поля. Изменение операции
сохраняет её вид; ограничения связанных возвратов объясняются у действия.
Перед удалением операции подтверждение показывает сумму и последствия,
для перевода — обе карты. При конфликте версии или зависимых возвратах
форма сохраняет ввод и предлагает перечитать затронутую запись. Архивирование
не удаляет историю и имеет явное обратное действие.
**States and recovery:**
| Состояние | Представление и восстановление |
| --- | --- |
| Initial loading | Структурный skeleton соответствующих областей, доступное состояние занятости, без фиктивных цифр и фокуса на декорациях |
| Нет карт | «Добавьте первую карту» и действие добавления |
| Пустой месяц | Нет операций/расходов за месяц, известные нулевые суммы, сохранённый текущий баланс |
| Ошибка без данных | Понятное сообщение и повторная загрузка, без подмены данных нулями |
| Фоновое обновление или его ошибка | Последние успешные данные остаются; рядом progress или предупреждение и retry |
| Локальная ошибка сводки | Доступная карта и журнал остаются видимыми |
| Invalid / pending формы | Ошибки полей без запроса; при отправке действия формы блокируются, включая закрытие |
| Ошибка сохранения | Alert, сохранённый ввод и безопасный повтор того же действия; форма закрывается только после определённого успеха |
| Чужая/отсутствующая карта | Нейтральное «Карта не найдена» и переход к списку без раскрытия чужих данных |
Формы используют Mantine Modal, React Hook Form, Zod и FormProvider.
Модальное окно удерживает фокус и возвращает его вызвавшему действию.
Поля имеют видимые подписи, кнопки — доступные названия, суммы — знак
и текст вида операции независимо от цвета. Продуктовые строки принадлежат
существующему русскому i18n-словарю.
**Responsive behavior:** на широком экране карты размещаются в две колонки,
на узком — в одну; заголовки и действия переносятся. Формы становятся
одноколоночными и помещаются по ширине экрана. Длинные названия и суммы
не скрывают действия; журнал и формы не перекрываются нижней навигацией.
Пятый пункт навигации «Карты» проверяется на ширине 320–375 px. Фокус,
клавиатурное управление и reduced motion сохраняются.
**Reused source and differences:** это новый пакет на текущей общей теме,
а не повторное использование архивного UI-пакета. Поэтому immutable locator
чужого change не требуется. Переиспользуются реальные элементы реализации:
- [AppShell](../../../../frontend/src/shared/ui/AppShell.tsx) — один пункт
«Карты» после «Счета» для desktop и mobile; текущие разделы сохраняются.
- [ProtectedLayout](../../../../frontend/src/app/routes/ProtectedLayout.tsx)
и [AppRoutes](../../../../frontend/src/app/routes/AppRoutes.tsx) — локальная
сессия и два lazy-маршрута; крошки и заголовок документа расширяются у
существующих владельцев.
- [EntryCard](../../../../frontend/src/shared/ui/EntryCard.tsx) — представление
строки операции с banking mapping. `LedgerEntryRow` и брокерские DTO
напрямую не переиспользуются.
- [AccountOperationsPage VM](../../../../frontend/src/pages/account-operations/ui/AccountOperationsPage.vm.ts)
и [форма периода дохода](../../../../frontend/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx)
— примеры организации URL/форм, без cross-page imports.
- [Дизайн-система](../../../../docs/development/design-system.md) — текущие
светлая тема, семантические цвета и размеры. Новые шрифты, палитра и
глобальная переработка оформления не вводятся.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Раздел карт, операции/категории/сводки, формы и состояния, generated client, локальная навигация и query invalidation |
| backend | `affected` | Владелец ручного учёта, хранение, транзакции, точные расчёты, API и общие Money-компоненты без изменения брокерской семантики |
| specs | `affected` | Новый контракт `manual-bank-cards` и SDD-артефакты этого change; текущие инвестиционные requirements сохраняются |
| docs | `affected` | После поставки: область банковских карт, глоссарий, обзор продукта/навигация, расширение описания хранимых данных и восстановление |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `required` | Новый backend API потребляется frontend; runtime document, snapshot и generated types должны совпадать |
## Solution Assessment
Оценка обязательна из-за новых долговременных финансовых данных, транзакций
и миграции. Выбирается существующая инфраструктура, с небольшой доменной
реализацией внутри приложения.
| Вариант | Fit, зрелость и безопасность | Эксплуатация, ограничения и миграция | Решение |
| --- | --- | --- | --- |
| Установленные Prisma + SQLite | Соответствуют локальному приложению; существующий DB provider, FK, unique constraints и транзакции | Без отдельного сервера; добавление таблиц сохраняет действующие данные; остаётся известная проблема migration engine | Использовать |
| Другой сервер БД, например PostgreSQL | Зрелые средства конкурентной записи; текущая нагрузка не требует смены SQLite | Новый сервис, конфигурация, резервирование и миграция всей базы | Не добавлять в эту итерацию |
| Browser storage | Нативное средство, но не соответствует сохранению серверных пользовательских данных и доступу через существующую сессию | Появляется второй источник истины и отдельная миграция из браузера | Не использовать для финансовой истории |
| Prisma Decimal поверх SQLite | Удобное имя типа не доказывает точный путь хранения | В установленном adapter наблюдалось преобразование Decimal через `parseFloat`; BigInt сохраняет целые значения | Хранить копейки в BigInt; проверить реальный roundtrip |
| Изменяемый cached balance или event-sourced ledger | Возможны при иных объёмах и требованиях аудита; сейчас добавляют второе состояние или инфраструктуру событий | Дополнительные восстановление, проекции, invalidation и миграционные обязательства | Вычислять из трёх простых моделей |
Наблюдение adapter относится к установленной зависимости, зафиксированной
в [backend package-lock](../../../../backend/package-lock.json), и её подключению
в [PrismaService](../../../../backend/src/prisma/prisma.service.ts).
Это не заменяет будущую проверку сохранения значений больше `2^53` через
реальную БД и не требует изменения версии зависимости на этапе Design.
## Risks / Trade-offs
- Пропущенные ручные движения приводят к расхождению с банком. Отрицательный
учётный баланс допустим; приложение не проверяет банковский доступный лимит.
- Возврат прошлой покупки может дать отрицательный месячный итог. UI показывает
расходы и возвраты отдельно, а не скрывает это округлением или обнулением.
- Редактирование истории меняет прошлые сводки. Здесь нет неизменяемого
бухгалтерского аудита; soft deletion предотвращает повторное применение
удалённой записи, но не является полной историей всех её редакций.
- Точный fold имеет O(N) стоимость. Нужную оптимизацию выбирают после измерения
фактической истории, сохраняя одного владельца расчёта.
- В [текущих ограничениях проверки](../../../../docs/development/verification.md#известное-ограничение-сквозной-проверки)
зафиксирован `Schema engine error` Prisma 7.9.1/Node 22 на новой SQLite.
В этой фазе он не воспроизводился и не признан устранённым. Финансовую
поставку нельзя признать проверенной без реальной DB/migration границы.
- Существующая ValidationPipe не доказывает обещанные структурированные
field errors — это отмечено в [HTTP API](../../../../docs/architecture/http-api.md#error-contract).
Формы имеют локальную валидацию и безопасный общий ответ об ошибке;
исполняемое поведение нового API проверяется на настроенном HTTP-приложении.
## Migration Plan
Сначала подтверждаются сохранность действующей базы и применимость известных
ограничений миграций на изолированной копии. Backup включает согласованный
снимок SQLite; ключ T-Bank сохраняется отдельно и не выводится в отчёты.
Работающая база и её WAL не копируются несогласованными частями: используется
корректный SQLite backup либо остановленное приложение.
Изменение схемы additive: три таблицы, индексы и связи с `User`.
Существующие строки User, Session и TbankConnection не переписываются.
Применённые migrations не редактируются; schema reset и обход migration
history не используются. Категории создаются для владельца при первом вводе,
без массового изменения всех существующих профилей.
Новый backend и его публичный контракт готовятся до включения нового
frontend-потребителя. Перенос общих Money-компонентов сохраняет действующий
контракт брокерских сценариев. Документация описывает поставленный результат
после проверки, а не объявляет будущие возможности готовыми.
Если migration engine мешает проверить additive-переход, сначала решается
это подтверждённое препятствие в допустимых границах. Unit и OpenAPI success
не подменяют миграцию на изолированной реальной базе. Условие пересмотра
известного blocker сохраняется у [владельца проверки](../../../../docs/development/verification.md#известное-ограничение-сквозной-проверки).
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Trigger | Регрессия входа/действующих данных, неверные финансовые результаты, нарушение изоляции владельцев или неуспешная миграция |
| Actions | Прекратить новые записи, сохранить актуальную базу; вернуть совместимую предыдущую версию приложения, сохранив добавленные таблицы. Не выполнять destructive down-migration |
| Data safety | Проверенный backup до миграции и актуальный снимок после остановки записей сохраняются отдельно. Старый backup не восстанавливается поверх новой истории как штатный rollback: это потеряло бы введённые операции |
| Verification | На disposable копии подтверждаются сохранность действующих пользователей/сессий/подключения, совместимость старого приложения с расширенной схемой, доступность новых записей после возврата новой версии и восстановление согласованного снимка |
Более детальная последовательность доставки и команды проверок принадлежат
следующему артефакту Tasks. Этот документ не разрешает менять пользовательскую
базу или начинать реализацию до согласования подготовленного решения.
@@ -0,0 +1,111 @@
## Why
WealthLedger показывает инвестиции и пассивный доход, но пользователю также
нужен раздел личных банковских карт: сколько денег на каждой карте, какие
операции происходили и к каким категориям относятся повседневные траты.
Первая итерация позволяет самостоятельно добавить карты и вести операции.
Пользователь выбрал последовательную подготовку SDD-артефактов: сначала
концепция и требования, затем Design, Tasks и реализация.
## What Changes
**Статус:** ответы Q1–Q9 согласованы. [Delta Specs](specs/manual-bank-cards/spec.md)
дополнены правилами целостного ручного учёта для рассмотрения вместе с Design.
Конкретизация требований ниже не является дословным ответом пользователя
на каждый частный случай и не означает начала реализации.
### Согласованный результат
- Q1: каждая вручную добавленная карта имеет независимые баланс и историю.
- Q2: баланс рассчитывается из начального остатка и внесённых операций.
- Q3: первая версия поддерживает дебетовые карты в рублях.
- Q4: доступны расход, пополнение, перевод между своими картами и возврат
покупки. Перевод вводится одним действием и не считается расходом.
- Q5: есть начальный набор категорий; пользователь может добавлять
и переименовывать свои категории без вложенных подкатегорий.
- Q6: журнал дополняется месячной сводкой расходов по категориям для выбранной
карты и всех банковских карт пользователя вместе.
- Q7: отрицательный учётный баланс допустим, в том числе до внесения
пропущенного пополнения.
- Q8: операции можно исправлять и удалять с пересчётом результатов;
ненужную карту можно убрать в архив с сохранением истории.
- Q9: возврат уменьшает расходы месяца, в котором он произошёл;
сводка отдельно показывает расходы, возвраты и итог после возвратов.
### Целостность пользовательского сценария
Пользователь задаёт начало учёта и начальный остаток, вносит операции
с категориями и датами, исправляет ошибки и видит согласованные баланс,
историю и месячные сводки. Частичные возвраты связаны с исходной покупкой.
Архив скрывает неиспользуемую карту из обычного списка, сохраняя её вклад
в исторические сводки; для новых операций и финансовых исправлений карту
нужно вернуть из архива.
Раздел банковских карт доступен после входа в WealthLedger независимо
от подключения инвестиционного счёта. Пользовательские данные сохраняются
при повторном входе и перезапуске приложения, остаются доступны только
владельцу и защищены от дублирования при повторной отправке и от потери
одновременных изменений.
Повтор создания карты или операции после потерянного ответа сохраняет один
результат, включая начальный остаток карты.
### Термины
**Карта:** добавленная пользователем дебетовая банковская карта в рублях,
для которой он отдельно ведёт баланс и историю операций. Она не является
брокерским счётом или общим счётом нескольких карт.
**Баланс карты:** сумма денег по данным ручного учёта, рассчитанная из начального
остатка и внесённых операций. Она не подтверждает текущий остаток в банковском
приложении: пропущенные движения могут привести к расхождению.
**Итог расходов после возвратов:** расходы выбранного месяца за вычетом
возвратов, произошедших в этом месяце; итог может быть отрицательным.
После реализации определения дополнят существующий
[глоссарий](../../../../docs/glossary.md); отдельный постоянный глоссарий
не создаётся.
### Границы первой итерации
Не входят автоматическая загрузка данных из банков, кредитные карты, другие
валюты, общий денежный баланс нескольких карт, объединение банковских денег
с инвестициями, прогнозы и бюджеты. Не предусмотрены вложенные подкатегории,
удаление категорий и безвозвратное удаление карт.
Произвольной операции «исправить баланс» нет: расхождение исправляется
изменением начального остатка либо внесением или исправлением реальной
операции. Месячная сводка всех карт показывает траты и возвраты, сохраняя
независимый баланс каждой карты.
## Capabilities
### New Capabilities
- `specs/manual-bank-cards/spec.md`: ручное добавление и архив банковских карт,
точный баланс по операциям, расходы и категории, пополнения, переводы,
связанные возвраты, исправления и месячные сводки.
### Modified Capabilities
Нет. Новый раздел сохраняет поведение существующих инвестиционных
представлений и не расширяет понятие Brokerage Account банковскими картами.
## Impact
- Пользователь получает учёт банковских карт и повседневных трат с историей
и возможностью исправить введённые данные.
- Frontend: отдельный раздел карт, формы операций и исправлений, архив,
категории и месячные сводки.
- Backend: сохранение пользовательского ручного учёта, точный расчёт
и защита целостности, приватности и одновременных изменений.
- Specs: проверяемый контракт новой возможности через SDD.
- Docs: описание новой предметной области и обновление глоссария при поставке.
Существующие [брокерские счета](../../../../docs/domains/accounts/overview.md)
получаются через инвестиционное подключение; банковские счета исключены их
[контрактом](../../specs/brokerage-accounts/spec.md).
Текущий [журнал операций](../../../../docs/domains/operations/overview.md)
описывает историю брокера для чтения и не рассчитывает остаток после операции.
Правила нового ручного учёта определяют Delta Specs этого изменения.
@@ -0,0 +1,688 @@
## Purpose
Позволяет пользователю вручную вести дебетовые банковские карты в рублях:
сохранять операции, видеть независимые балансы, исправлять записи и оценивать
месячные расходы по категориям с учётом возвратов.
## Статус согласования
[Proposal](../../proposal.md) фиксирует согласованные ответы Q1–Q9.
Эта редакция раскрывает их в целостный предлагаемый контракт для рассмотрения
вместе с Design. Ограничения полей, даты, категории и правила целостности ниже
являются конкретизацией требований, а не отдельными цитатами пользователя.
Открытых продуктовых развилок в этой редакции нет; наличие спецификации
не означает, что соответствующее поведение уже реализовано.
## ADDED Requirements
### Requirement: Отдельный раздел ручного учёта банковских карт
Система SHALL предоставлять авторизованному пользователю раздел банковских
карт на `/cards` и просмотр отдельной своей карты на `/cards/:id`.
Раздел MUST работать без подключения T-Bank и не зависеть от доступности
инвестиционных данных. Банковские карты MUST NOT становиться брокерскими
счетами, а банковские суммы и операции MUST NOT включаться в инвестиционный
портфель, пассивный доход или прогноз выплат.
Первая версия SHALL поддерживать только ручной учёт дебетовых карт в RUB.
Автоматическая загрузка из банков, кредитные карты, другие валюты, общий
денежный баланс нескольких карт, прогнозы и бюджеты не входят в возможность.
#### Scenario: Работа без инвестиционного подключения
- **WHEN** пользователь с действующей локальной сессией, но без подключения T-Bank, открывает раздел карт
- **THEN** он может просматривать и вести свои банковские карты без перехода к подключению брокера
#### Scenario: Независимость от инвестиционных данных
- **WHEN** пользователь сохраняет банковское пополнение или расход, а инвестиционные данные временно недоступны
- **THEN** банковская операция сохраняется по правилам ручного учёта и не меняет инвестиционные суммы или пассивный доход
### Requirement: Ручное добавление независимых банковских карт
Система SHALL позволять пользователю добавить карту с названием, начальным
остатком и датой начала учёта. Название MUST содержать от 1 до 100 символов
после удаления внешних пробелов. Название банка необязательно и MUST содержать
не более 100 символов после удаления внешних пробелов. Последние четыре цифры
карты необязательны; указанное значение MUST состоять ровно из четырёх цифр.
Полный номер карты, срок действия, PIN и код безопасности не запрашиваются
и не сохраняются как реквизиты этой возможности.
Каждая карта MUST иметь собственную идентичность, баланс и историю независимо
от совпадения банка, названия или последних цифр. Пользователь SHALL иметь
возможность изменить указанные сведения о своей карте с теми же ограничениями.
Финансовые изменения начального остатка и даты начала подчиняются отдельным
требованиям исправления и архива.
#### Scenario: Добавление карты с начальным остатком
- **WHEN** пользователь сохраняет карту с допустимым названием, датой начала учёта и начальным остатком 10 000 ₽
- **THEN** карта появляется в его списке с балансом 10 000 ₽ и без вручную внесённых операций
#### Scenario: Необязательные сведения не заполнены
- **WHEN** пользователь добавляет карту без названия банка и последних четырёх цифр
- **THEN** отсутствие этих сведений не препятствует сохранению карты при корректности обязательных значений
#### Scenario: Несколько карт одного банка
- **WHEN** пользователь добавляет две карты одного банка с одинаковым названием и начальными остатками 1 000 ₽ и 2 000 ₽
- **THEN** система сохраняет две самостоятельные карты с раздельными балансами и историей
#### Scenario: Некорректные сведения карты
- **WHEN** название после удаления внешних пробелов пустое или длиннее 100 символов, название банка длиннее 100 символов либо указанные последние цифры не состоят ровно из четырёх цифр
- **THEN** система отклоняет сохранение, объясняет ошибку соответствующего поля и не создаёт или не изменяет карту
### Requirement: Точные денежные значения и проверка до сохранения
Система MUST принимать денежные значения только в RUB, с точностью не более
двух знаков после десятичного разделителя, без округления введённой суммы.
Сумма расхода, пополнения, перевода или возврата MUST быть строго положительной
и не превышать 92 233 720 368 547 758,07 ₽. Начальный остаток MUST находиться
в диапазоне от −92 233 720 368 547 758,08 ₽ до 92 233 720 368 547 758,07 ₽
включительно; ноль и отрицательный начальный остаток допустимы.
Значение, не представляющее корректную денежную сумму, неподдерживаемая
валюта, лишние десятичные знаки или выход за диапазон MUST приводить
к понятной ошибке без частичного сохранения. Применение ограничения
к каждому денежному вводу MUST NOT приводить к округлению, переполнению
или ограничению точного результата сложения уже сохранённых сумм.
#### Scenario: Нулевой и отрицательный начальный остаток
- **WHEN** пользователь сохраняет отдельные карты с начальными остатками 0 ₽ и −100 ₽
- **THEN** система сохраняет обе карты и показывает соответствующие известные балансы
#### Scenario: Неположительная сумма операции
- **WHEN** пользователь пытается внести операцию с суммой 0 ₽ или −1 ₽
- **THEN** система отклоняет операцию, сохраняя прежние историю, балансы и сводки
#### Scenario: Точность не исправляется округлением
- **WHEN** пользователь пытается сохранить начальный остаток или операцию со значением 1,001 ₽
- **THEN** система сообщает о допустимых двух десятичных знаках и не заменяет введённое значение на 1 ₽
#### Scenario: Границы денежных значений
- **WHEN** пользователь указывает верхнюю допустимую сумму операции или нижний допустимый начальный остаток
- **THEN** сами эти граничные значения принимаются, а выход за соответствующую границу на одну копейку отклоняется без изменения данных
#### Scenario: Некорректная сумма или валюта
- **WHEN** поступает попытка сохранить нечисловую сумму либо сумму в валюте, отличной от RUB
- **THEN** система объясняет ошибку и не сохраняет карту, операцию или изменение существующих данных
### Requirement: Даты начала учёта и ручных операций
Дата начала учёта и дата операции MUST быть существующими календарными датами
без времени. Сегодня определяется по Europe/Moscow. Дата начала учёта
MUST NOT быть позже сегодняшней даты. Начальный остаток означает деньги
до всех вручную внесённых операций начиная с указанной даты включительно.
Дата операции MUST находиться между датой начала учёта затронутой карты
и сегодняшним днём включительно. Для перевода ограничение MUST выполняться
для обеих карт. Ввод прошедших операций внутри этого интервала SHALL быть
доступен. Необязательное описание операции MUST содержать не более
500 символов. Сохранённая календарная дата MUST NOT сдвигаться при смене
часового пояса устройства.
#### Scenario: Операция в первый день учёта
- **WHEN** при начальном остатке 1 000 ₽ на 1 сентября пользователь вносит расход 100 ₽ с датой 1 сентября
- **THEN** расход учитывается после начального остатка и баланс становится 900 ₽
#### Scenario: Прошедшая операция
- **WHEN** пользователь вносит операцию задним числом не раньше начала учёта и не позже сегодняшнего дня
- **THEN** операция сохраняется с указанной датой и изменяет текущий баланс и сводку соответствующего месяца
#### Scenario: Недопустимая дата или описание
- **WHEN** дата не существует, находится до начала учёта или в будущем либо описание длиннее 500 символов
- **THEN** система объясняет ошибку и не сохраняет изменение
#### Scenario: Перевод до начала учёта получателя
- **WHEN** дата перевода допустима для карты-источника, но предшествует началу учёта карты-получателя
- **THEN** перевод отклоняется без изменения обеих карт
#### Scenario: Московская календарная граница
- **WHEN** на устройстве ещё предыдущий день, а в Europe/Moscow уже наступил следующий
- **THEN** допустимая сегодняшняя дата и текущий месяц определяются по Europe/Moscow, а сохранённые даты операций остаются прежними
### Requirement: Баланс определяется начальным остатком и внесёнными операциями
Баланс карты MUST равняться начальному остатку плюс денежное влияние всех
её сохранённых и не удалённых операций: пополнение и возврат увеличивают
баланс, расход уменьшает, перевод уменьшает баланс источника и увеличивает
баланс получателя. Начальный остаток MUST учитываться ровно один раз
и MUST NOT считаться расходом, возвратом или пополнением.
Баланс MUST рассчитываться точно и может быть отрицательным. Недостаточный
учётный остаток MUST NOT препятствовать сохранению корректного расхода
или перевода. Баланс MUST NOT зависеть от выбранного месяца, категории,
показанной страницы истории или нахождения карты в архиве.
Система SHALL представлять баланс как результат ручного учёта и MUST NOT
выдавать его за подтверждённый банком остаток или обещать полноту невнесённых
операций. Отдельной произвольной операции корректировки баланса SHALL NOT быть.
#### Scenario: Последовательные расходы с копейками
- **WHEN** при начальном остатке 1 ₽ сохраняются расходы 0,10 ₽ и 0,20 ₽
- **THEN** баланс составляет ровно 0,70 ₽
#### Scenario: Полное расходование и отрицательный остаток
- **WHEN** при балансе 100 ₽ пользователь сохраняет расход 100 ₽, затем расход 50 ₽
- **THEN** первая операция даёт известный баланс 0 ₽, вторая сохраняется и даёт баланс −50 ₽
#### Scenario: Начальный остаток и страница истории
- **WHEN** при начальном остатке 1 000 ₽ и расходах 100 ₽ и 200 ₽ пользователь повторно открывает карту и видит только расход 200 ₽
- **THEN** текущий баланс остаётся 700 ₽: учитываются обе операции, а начальный остаток не добавляется повторно
#### Scenario: Итог превышает границу отдельного ввода
- **WHEN** на карту с начальным остатком 92 233 720 368 547 758,07 ₽ сохраняется пополнение 0,01 ₽
- **THEN** баланс составляет точно 92 233 720 368 547 758,08 ₽ без переполнения, округления или ограничения верхней границей ввода
### Requirement: Ручное внесение расходов и пополнений
Система SHALL позволять вручную внести расход или пополнение по своей активной
карте с суммой, датой и необязательным описанием. Расход MUST иметь категорию;
если пользователь не выбирает другую, применяется его категория по умолчанию.
После успешного сохранения операция MUST появиться в истории соответствующей
карты и ровно один раз изменить её баланс.
Расход SHALL уменьшать баланс на свою сумму, пополнение SHALL увеличивать.
Категория MUST NOT изменять денежное влияние расхода. Пополнение MUST NOT
включаться в расходы, возвраты или пассивный инвестиционный доход.
#### Scenario: Расход с категорией
- **WHEN** при балансе 10 000 ₽ пользователь сохраняет расход 500 ₽ с категорией «Кафе»
- **THEN** история показывает расход 500 ₽ с этой категорией, а баланс составляет 9 500 ₽
#### Scenario: Категория по умолчанию
- **WHEN** пользователь сохраняет расход, не выбрав другую категорию
- **THEN** расход сохраняется с принадлежащей пользователю категорией по умолчанию и отображается под её текущим названием
#### Scenario: Пополнение карты
- **WHEN** при балансе −500 ₽ пользователь сохраняет пополнение 2 000 ₽
- **THEN** баланс становится 1 500 ₽, история содержит пополнение, а расходы и возвраты не меняются
#### Scenario: Независимость другой карты
- **WHEN** при балансах A 1 000 ₽ и B 2 000 ₽ пользователь сохраняет расход 125,50 ₽ по A
- **THEN** баланс A становится 874,50 ₽, а баланс и история B остаются прежними
### Requirement: Перевод между своими картами как одно действие
Система SHALL позволять одним действием внести перевод между двумя разными
активными картами одного пользователя. Сумма перевода MUST одинаково уменьшать
баланс источника и увеличивать баланс получателя. История каждой стороны
MUST показывать направление и другую карту этого перевода.
Перевод MUST сохраняться, исправляться и удаляться целиком для обеих сторон.
Нельзя сохранять только одну сторону, переводить на ту же или чужую карту.
Перевод MUST NOT увеличивать расходы или возвраты в сводках одной или всех карт.
#### Scenario: Перевод между картами
- **WHEN** при балансах A 5 000 ₽ и B 1 000 ₽ пользователь сохраняет перевод 2 000 ₽ из A в B
- **THEN** баланс A становится 3 000 ₽, баланс B — 3 000 ₽, обе истории показывают стороны одного перевода, а расходы не меняются
#### Scenario: Недостаточный остаток источника
- **WHEN** при балансах A 100 ₽ и B 0 ₽ пользователь сохраняет перевод 150 ₽ из A в B
- **THEN** перевод сохраняется целиком, баланс A становится −50 ₽, а баланс B — 150 ₽
#### Scenario: Недопустимый получатель
- **WHEN** получателем указана та же карта или карта другого пользователя
- **THEN** система отклоняет перевод без изменения историй, балансов и сводок
#### Scenario: Сбой сохранения перевода
- **WHEN** перевод не удаётся сохранить
- **THEN** система не подтверждает успех и не оставляет сохранённой только одну его сторону
### Requirement: Возврат связан с исходным расходом
Возврат SHALL ссылаться на существующий не удалённый расход той же карты.
При внесении возврата пользователь SHALL иметь возможность выбрать исходный
расход карты за любой период учёта, независимо от месяца открытого журнала.
Частичные и несколько возвратов допустимы; сумма всех не удалённых возвратов
MUST NOT превышать текущую сумму исходного расхода. Дата возврата MUST быть
не раньше даты расхода и соответствовать общим ограничениям даты карты.
При редактировании возврата проверка общей возвращённой суммы MUST заменять
его прежнюю сумму новой, не учитывая обе версии одновременно.
Возврат MUST увеличивать баланс карты и наследовать категорию исходного
расхода. Отдельный выбор категории возврата SHALL NOT предоставляться.
Изменение категории исходного расхода MUST менять категорию связанных
возвратов и результаты затронутых месячных сводок, сохраняя их суммы и даты.
#### Scenario: Частичные возвраты
- **WHEN** по расходу 1 000 ₽ пользователь последовательно сохраняет возвраты 300 ₽ и 700 ₽
- **THEN** оба возврата связаны с расходом, увеличивают баланс суммарно на 1 000 ₽ и используют его категорию
#### Scenario: Выбор покупки прошлого месяца
- **WHEN** пользователь открывает сентябрьский журнал и вносит возврат по августовской покупке
- **THEN** форма позволяет найти и выбрать исходный августовский расход этой карты без ограничения сентябрьским журналом
#### Scenario: Исправление полностью возвращённой покупки
- **WHEN** по расходу 500 ₽ сохранён единственный возврат 500 ₽ и пользователь изменяет только описание возврата
- **THEN** исправление сохраняется: общая возвращённая сумма остаётся 500 ₽, а прежняя и новая версии не складываются как два возврата
#### Scenario: Превышение возвращаемой суммы
- **WHEN** при расходе 1 000 ₽ и сохранённом возврате 800 ₽ пользователь пытается сохранить ещё 300 ₽
- **THEN** система отклоняет новый возврат без изменения существующих записей и сумм
#### Scenario: Недопустимая связь или дата возврата
- **WHEN** возврат ссылается на удалённую операцию, операцию другого вида или другой карты либо датирован раньше расхода
- **THEN** система отклоняет возврат без изменения истории, баланса и сводок
#### Scenario: Изменение категории покупки
- **WHEN** пользователь меняет категорию расхода с «Покупки» на «Здоровье», а возврат по нему произошёл в другом месяце
- **THEN** расход и возврат учитываются в категории «Здоровье» в своих месяцах с прежними денежными суммами
### Requirement: Общие для пользователя категории расходов
Система SHALL предоставлять каждому пользователю собственный общий для всех
его карт список категорий: «Продукты», «Кафе», «Такси», «Транспорт», «Жильё»,
«Здоровье», «Покупки», «Развлечения», «Другое». «Другое» является начальной
категорией по умолчанию. Категории MUST быть доступны до первого расхода.
Начальный набор MUST создаваться при успешном добавлении первой карты
пользователя и не дублироваться при добавлении следующих карт.
Пользователь SHALL иметь возможность добавлять и переименовывать любые свои
категории, включая начальные. Название MUST содержать от 1 до 80 символов
после удаления внешних пробелов и быть уникальным среди категорий пользователя
без учёта регистра. Переименование MUST сохранять идентичность категории:
все старые расходы, возвраты и сводки показывают её текущее название.
Переименование категории по умолчанию MUST сохранять её роль.
Удаление категорий и вложенные подкатегории SHALL NOT предоставляться в V1.
#### Scenario: Начальный набор и повторный вход
- **WHEN** пользователь сохраняет первую карту, затем добавляет следующую карту и повторно входит в приложение
- **THEN** после первой карты ему доступен начальный набор категорий до первого расхода, а дальнейшие действия не создают дубликатов
#### Scenario: Категория доступна на всех своих картах
- **WHEN** пользователь создаёт категорию «Питомец»
- **THEN** она доступна для расходов по всем его картам и не появляется у другого пользователя
#### Scenario: Переименование используемой начальной категории
- **WHEN** пользователь переименовывает категорию «Другое» в «Остальное»
- **THEN** прежние записи отображаются как «Остальное», суммы не меняются, а новые расходы без другого выбора используют ту же категорию «Остальное»
#### Scenario: Дублирующееся или недопустимое название
- **WHEN** пользователь пытается добавить « кафе » при существующей «Кафе», сохранить пустое после удаления пробелов название или название длиннее 80 символов
- **THEN** система объясняет ошибку и не изменяет список категорий
### Requirement: Исправление и удаление операций с согласованным пересчётом
Пользователь SHALL иметь возможность изменить сумму, дату, описание
и применимые реквизиты сохранённой операции либо удалить её. Вид операции
и связь возврата с исходным расходом MUST быть неизменны после создания.
Все затронутые карты MUST быть активны; каждое исправление MUST соблюдать
те же денежные, календарные и пользовательские ограничения, что и создание.
Обычный расход или пополнение SHALL быть переносимы на другую свою активную
карту. Расход с не удалёнными возвратами MUST NOT переноситься на другую карту,
удаляться, уменьшаться ниже суммы возвратов или сдвигаться на дату позже
любого связанного возврата. Для такого действия сначала исправляются
или удаляются мешающие возвраты. Возврат остаётся на карте исходного расхода.
Исправление или удаление MUST пересчитать балансы, историю и все затронутые
месячные сводки; изменение даты переносит влияние между соответствующими
месяцами. Исправление сторон перевода MUST учитывать его прежние и новые
карты. Удаление перевода MUST удалять обе стороны одним действием.
Повторное удаление уже удалённой собственной операции SHALL быть
безрезультатным успешным действием без повторного денежного влияния.
#### Scenario: Изменение суммы и месяца расхода
- **WHEN** пользователь меняет расход 100 ₽ за август на 150 ₽ за сентябрь
- **THEN** баланс дополнительно уменьшается на 50 ₽, август перестаёт учитывать 100 ₽, а сентябрь учитывает 150 ₽
#### Scenario: Перенос пополнения на другую карту
- **WHEN** пользователь переносит пополнение 500 ₽ с A на свою активную B с допустимой датой
- **THEN** баланс A уменьшается на 500 ₽, баланс B увеличивается на 500 ₽, а запись относится к B
#### Scenario: Изменение получателя перевода
- **WHEN** перевод 200 ₽ из A в B исправляется на перевод 300 ₽ из A в C и все три карты активны
- **THEN** прежнее влияние на B отменяется, A уменьшается дополнительно на 100 ₽, C увеличивается на 300 ₽, а обе стороны исправленного перевода сохраняются согласованно
#### Scenario: Исправление расхода ограничено возвратами
- **WHEN** для расхода 1 000 ₽ существует возврат 600 ₽ и пользователь пытается удалить расход, перенести его на другую карту, уменьшить до 500 ₽ или сдвинуть позже даты возврата
- **THEN** система отклоняет действие и объясняет необходимость сначала исправить связанные возвраты
#### Scenario: Удаление возврата и затем расхода
- **WHEN** пользователь удаляет все возвраты расхода, а затем сам расход
- **THEN** влияние каждой удалённой записи отменяется в балансе и в её месячной сводке, а ограничение удаления исходного расхода больше не действует
#### Scenario: Удаление перевода из любой стороны
- **WHEN** пользователь удаляет перевод из истории получателя и затем повторяет удаление
- **THEN** обе стороны удаляются при первом действии, денежное влияние отменяется один раз, а повторное удаление ничего не меняет
#### Scenario: Смена вида или исходного расхода
- **WHEN** пользователь пытается превратить расход в пополнение либо связать существующий возврат с другим расходом
- **THEN** изменение отклоняется без подмены вида операции или её исходной связи
### Requirement: Исправление начала ручного учёта
Пользователь SHALL иметь возможность изменить начальный остаток и дату начала
учёта активной карты. Изменение MUST проверять всю затронутую сохранённую
историю, включая стороны переводов и связанные возвраты, и отклоняться
без частичного результата, если после него какая-либо операция нарушает
действующие ограничения. Удалённые операции не ограничивают новую дату.
Изменение начального остатка SHALL пересчитывать текущий баланс, сохраняя
суммы, даты и категории операций. Оно MUST NOT создавать отдельную операцию
или изменять месячные расходы и возвраты. Пропущенное движение SHALL
исправляться внесением реальной операции с допустимой датой.
#### Scenario: Исправление начального остатка
- **WHEN** при начальном остатке 1 000 ₽ и расходах 300 ₽ пользователь меняет начальный остаток на 1 200 ₽
- **THEN** баланс становится 900 ₽, история операций не пополняется корректировкой, а месячные расходы остаются 300 ₽
#### Scenario: Новое начало противоречит истории
- **WHEN** пользователь переносит дату начала учёта с 1 на 10 сентября, а по карте сохранён расход или перевод от 5 сентября
- **THEN** система отклоняет изменение и сохраняет прежние дату, баланс и всю историю
### Requirement: Архив карт сохраняет историю
Пользователь SHALL иметь возможность архивировать свою карту при любом
балансе, включая положительный, нулевой и отрицательный, и вернуть её
из архива. Архивация MUST NOT менять деньги или операции. Обычный список
SHALL показывать активные карты; переключатель архива SHALL открывать
архивные карты и их историю.
Пока карта архивна, система MUST запрещать создание, редактирование
и удаление затрагивающих её операций, а также изменение начального остатка
и даты начала учёта. Для исправления или новой операции карту сначала
возвращают из архива. Перевод или возврат MUST требовать активного состояния
всех затронутых карт. Изменение описательных сведений карты само по себе
не является финансовой операцией.
Архивная карта MUST продолжать участвовать в исторических сводках «Все карты».
Безвозвратное удаление карты SHALL NOT предоставляться в первой версии.
#### Scenario: Архивирование с ненулевым остатком
- **WHEN** пользователь архивирует карту с балансом −500 ₽ и сохранённой историей
- **THEN** карта скрывается из обычного списка, остаётся доступной через переключатель архива с теми же балансом и историей и сохраняет вклад в сводки всех карт
#### Scenario: Финансовое действие по архивной карте
- **WHEN** пользователь пытается внести, исправить или удалить операцию либо изменить начало учёта архивной карты
- **THEN** система отклоняет действие без изменения данных и объясняет необходимость вернуть карту из архива
#### Scenario: Возврат или перевод затрагивает архив
- **WHEN** возврат относится к расходу архивной карты либо хотя бы одна прежняя или новая сторона изменяемого перевода архивна
- **THEN** финансовое действие отклоняется до возвращения всех затронутых карт из архива
#### Scenario: Возвращение карты
- **WHEN** пользователь возвращает карту из архива
- **THEN** она снова видна в обычном списке с сохранёнными данными и допускает финансовые действия по общим правилам
### Requirement: Месячный журнал и сводка расходов по категориям
Система SHALL показывать историю и месячную сводку выбранной карты, а также
месячную сводку всех банковских карт пользователя. По умолчанию выбирается
текущий месяц Europe/Moscow; пользователь SHALL иметь возможность явно выбрать
другой месяц. История карты и её сводка MUST использовать один выбранный месяц.
Операции SHALL отображаться по убыванию календарной даты со стабильным порядком
внутри даты при повторном чтении неизменившихся данных и доступом к следующим
страницам истории.
Сводка MUST показывать расходы, возвраты и итог после возвратов, а также
соответствующие суммы по категориям. Расход входит в месяц собственной даты,
возврат — в месяц даты возврата, даже если покупка была раньше.
Итог MUST равняться расходам минус возвраты и может быть отрицательным.
Начальные остатки, пополнения и переводы MUST NOT включаться в эти суммы.
Сводка MUST учитывать все подходящие не удалённые записи, а не только
текущую страницу истории. «Все карты» MUST включать активные и архивные карты
владельца. Выбор месяца и охвата MUST NOT изменять текущие балансы.
При отсутствии расходов и возвратов SHALL показываться известные нули.
#### Scenario: Месяц по умолчанию и переключение
- **WHEN** пользователь открывает карту в сентябре по Europe/Moscow и затем выбирает август
- **THEN** сначала история и сводка относятся к сентябрю, после выбора обе относятся к августу, а текущий баланс остаётся прежним
#### Scenario: Сводка расходов и переводов
- **WHEN** в месяце сохранены расходы «Кафе» 500 ₽ и «Такси» 300 ₽, пополнение 2 000 ₽ и перевод 1 000 ₽ без возвратов
- **THEN** сводка показывает расходы 800 ₽, возвраты 0 ₽, итог 800 ₽ и категории 500 ₽ и 300 ₽ без включения пополнения или перевода
#### Scenario: Возврат покупки прошлого месяца
- **WHEN** расход «Покупки» 1 000 ₽ совершён в августе, возврат 600 ₽ — в сентябре и других операций этих видов нет
- **THEN** август показывает расходы 1 000 ₽, возвраты 0 ₽ и итог 1 000 ₽, а сентябрь — расходы 0 ₽, возвраты 600 ₽ и итог −600 ₽ в той же категории
#### Scenario: Все карты включают архивные
- **WHEN** расходы активной A за месяц составили 800 ₽, архивной B — 200 ₽, а между ними ранее был перевод 1 000 ₽
- **THEN** общая сводка показывает расходы 1 000 ₽ и не добавляет перевод, независимо от переключателя архива в списке карт
#### Scenario: Несколько страниц и равные даты
- **WHEN** операции выбранного месяца занимают несколько страниц и часть операций имеет одинаковую дату
- **THEN** пользователь может просмотреть все страницы в стабильном порядке, а сводка и баланс учитывают все соответствующие записи независимо от открытой страницы
### Requirement: Изоляция пользовательских финансовых данных
Карты, категории, операции и связи между ними MUST принадлежать локальному
пользователю WealthLedger. Система MUST предоставлять чтение, создание,
изменение, удаление, архив и восстановление только владельцу с действующей
сессией. Идентификатор сам по себе MUST NOT подтверждать доступ.
Проверка владельца MUST распространяться на каждую упомянутую карту,
категорию и операцию, включая обе стороны перевода, исходный расход возврата
и новые связи при редактировании. Отказ MUST NOT раскрывать чужие финансовые
сведения или менять данные любой стороны. Сводки MUST включать только
данные текущего пользователя.
#### Scenario: Нет действующей сессии
- **WHEN** пользователь без действующей сессии пытается прочитать карты, сводку или изменить финансовые данные
- **THEN** система отказывает в доступе без раскрытия и изменения сохранённых данных
#### Scenario: Чужая карта или категория
- **WHEN** пользователь обращается к чужой карте или пытается назначить своей операции чужую категорию
- **THEN** система отклоняет действие, не раскрывает чужие сведения и не меняет баланс, историю или категории
#### Scenario: Чужая связь перевода или возврата
- **WHEN** пользователь подставляет чужую карту в перевод или чужой расход в возврат при создании либо редактировании
- **THEN** система отклоняет всё действие без сохранения частичного результата и без раскрытия данных другого пользователя
#### Scenario: Чужие исправления и архив
- **WHEN** пользователь пытается изменить или удалить чужую операцию, переименовать чужую категорию либо архивировать или восстановить чужую карту
- **THEN** система отказывает и сохраняет все данные владельца без изменений
### Requirement: Согласованные изменения и защита повторной отправки
Успешное финансовое изменение MUST согласованно сохранять операции, связи,
денежное влияние и пересчитанные результаты для всех затронутых карт.
Система MUST NOT подтверждать успех несохранённого действия или оставлять
частичный результат. Независимые одновременные операции MUST сохранять
влияние каждой успешной записи, не затирая друг друга.
Повторная отправка одного намерения создать карту или операцию с тем же
содержанием MUST приводить не более чем к одному соответствующему результату
и однократному учёту начального остатка или денежного влияния операции.
Иное содержание при повторном использовании того же намерения MUST приводить
к конфликту без изменения данных. После исправления или удаления результата
поздняя повторная отправка исходного создания MUST NOT восстанавливать
старые значения, удалённую запись или её денежное влияние.
Ответ на повтор MUST показывать актуальное состояние ранее созданной карты
или операции либо сообщать, что операция удалена, не выдавая старые данные
за текущий результат. Уже сохранённое намерение MUST NOT проверяться
как новая финансовая запись и повторно расходовать доступную сумму возврата.
Архивирование ранее созданной карты MUST NOT превращать повтор её создания
в создание другой карты или возвращение из архива.
Редактирование или удаление существующей операции и изменение карты,
основанные на её устаревшем состоянии, MUST завершаться понятным конфликтом
без перезаписи более свежих изменений. Повторное удаление уже удалённой
операции не считается перезаписью и остаётся безрезультатным действием.
Проверки суммы возвратов, состояния архива, дат и других ограничений
MUST сохраняться при одновременных действиях.
#### Scenario: Повтор после потерянного ответа
- **WHEN** создание расхода 100 ₽ успешно сохранено, ответ потерян и пользователь повторяет то же намерение с тем же содержанием
- **THEN** история содержит один расход, баланс изменён один раз, а повтор не создаёт дубликат
#### Scenario: Повтор создания карты после архивации
- **WHEN** карта с начальным остатком 1 000 ₽ создана, ответ потерян, карта затем архивирована и приходит повтор её исходного создания
- **THEN** система показывает актуальную архивную карту, не создаёт вторую карту, не дублирует начальный остаток и не возвращает карту из архива
#### Scenario: Повтор полного возврата
- **WHEN** возврат 500 ₽ полностью покрывает расход 500 ₽, успешно сохранён, но ответ потерян и пользователь повторяет то же намерение создания возврата
- **THEN** система возвращает сохранённый возврат без новой записи, повторного изменения баланса или ошибки о превышении суммы покупки
#### Scenario: Изменённое содержание того же намерения
- **WHEN** после сохранения расхода 100 ₽ повтор того же намерения содержит сумму 200 ₽
- **THEN** система сообщает о конфликте и сохраняет исходный результат без дополнительной операции
#### Scenario: Позднее создание после исправления или удаления
- **WHEN** сохранённый расход 100 ₽ исправлен на 150 ₽ или удалён, после чего приходит повтор его исходного создания
- **THEN** система показывает актуальный расход 150 ₽ либо сообщает об удалении; прежняя сумма или удалённая запись не восстанавливаются и дополнительного денежного влияния нет
#### Scenario: Два независимых расхода
- **WHEN** при балансе 1 000 ₽ расходы 100 ₽ и 200 ₽ успешно сохраняются одновременно как разные намерения
- **THEN** история содержит оба расхода, а баланс составляет 700 ₽
#### Scenario: Два возврата превышают покупку вместе
- **WHEN** для расхода 1 000 ₽ одновременно сохраняются независимые возвраты 600 ₽ и 600 ₽
- **THEN** система не допускает общей сохранённой суммы 1 200 ₽: конфликтующее действие отклоняется без частичного результата
#### Scenario: Исправление по устаревшим данным
- **WHEN** операция или карта изменилась после того, как пользователь начал её редактирование, а он отправляет изменение прежнего состояния
- **THEN** система сообщает о конфликте, сохраняет более свежие данные и позволяет получить актуальное состояние для нового исправления
#### Scenario: Удаление по устаревшим данным
- **WHEN** пользователь пытается удалить существующую операцию, которую другой успешный запрос уже изменил
- **THEN** система отклоняет устаревшее удаление без потери более свежего исправления
#### Scenario: Архивирование одновременно с финансовым изменением
- **WHEN** архивирование карты пересекается с созданием или исправлением затрагивающей её операции
- **THEN** результаты соответствуют последовательному выполнению допустимых действий и не допускают финансовую запись, выполненную после вступившего в силу архива
### Requirement: Сохранённый ручной учёт переживает повторный вход и перезапуск
Успешно сохранённые карты, начальные остатки, даты начала, операции,
категории, архивное состояние и связи возвратов и переводов MUST оставаться
доступными владельцу после обновления страницы, выхода, повторного входа
и обычного перезапуска приложения. Эти действия MUST NOT создавать
дубликаты, восстанавливать удалённые операции или сбрасывать балансы.
Защита повторной отправки сохранённых намерений MUST сохранять действие
после этих переходов.
#### Scenario: Повторный вход
- **WHEN** пользователь выходит и снова входит после сохранения карты с начальным остатком 1 000 ₽ и расходом 100 ₽
- **THEN** он видит ту же карту, дату начала, расход с его текущей категорией и баланс 900 ₽
#### Scenario: Перезапуск с переводом, возвратом и архивом
- **WHEN** приложение перезапускается после сохранения переводов, связанных возвратов, переименования категорий и архивирования карты
- **THEN** владелец получает прежние связи, названия, архивное состояние и рассчитанные суммы без дубликатов или потери истории
#### Scenario: Повтор создания после перезапуска
- **WHEN** после перезапуска повторяется намерение создания ранее сохранённой, а затем удалённой операции
- **THEN** удалённая операция и её денежное влияние не восстанавливаются
### Requirement: Доступные формы и различимые состояния без ложного успеха
Раздел SHALL различать отсутствие карт, отсутствие операций за месяц,
известный нулевой баланс, загрузку и ошибку получения данных. Ошибка
MUST NOT подменяться пустым списком или нулевой суммой. Пользователь SHALL
иметь возможность повторить неуспешную загрузку.
Формы карт, операций, исправлений и категорий MUST иметь доступные названия
полей и действий, управление с клавиатуры и понятные ошибки, связанные
с соответствующим полем или действием. Обязательность, направление денег
и ошибки MUST NOT передаваться только цветом.
Во время сохранения, ошибки и конфликта введённый черновик MUST оставаться
доступным до явного закрытия или успешного завершения. Пока результат
сохранения не подтверждён, система MUST NOT показывать введённую сумму
как подтверждённый новый баланс или выдавать действие за успешное.
При неопределённом результате после потери ответа повтор SHALL использовать
защиту того же намерения создания. После подтверждённого изменения
отображаемые история, балансы и сводки MUST согласовываться с сохранёнными
данными всех затронутых карт.
#### Scenario: Пустой список
- **WHEN** раздел успешно загружен и у пользователя ещё нет карт
- **THEN** система показывает штатное пустое состояние с доступным действием добавления карты
#### Scenario: Нет операций при ненулевом балансе
- **WHEN** в выбранном месяце нет операций, но текущий баланс карты составляет 10 000 ₽
- **THEN** история сообщает об отсутствии операций за месяц, сводка содержит нули, а текущий баланс остаётся 10 000 ₽
#### Scenario: Ошибка загрузки
- **WHEN** загрузка карт, истории или сводки завершается ошибкой
- **THEN** система показывает ошибку и действие повтора, не выдавая её за отсутствие данных или нулевой результат
#### Scenario: Доступный ввод и проверка
- **WHEN** пользователь управляет формой с клавиатуры или средством чтения экрана и вводит некорректную сумму
- **THEN** названия полей и действий доступны, ошибка суммы определима без цвета, а введённые значения остаются для исправления
#### Scenario: Ожидание и неопределённый результат
- **WHEN** пользователь отправляет перевод и ответ ещё не получен либо потерян
- **THEN** черновик сохраняется, неподтверждённые балансы не выдаются за сохранённые, а повтор того же создания не порождает второй перевод
#### Scenario: Ошибка или конфликт исправления
- **WHEN** сохранение исправления отклоняется из-за ошибки или устаревшего состояния
- **THEN** пользователь видит причину, сохраняет черновик и может получить актуальные данные для следующего действия без перезаписи чужого успешного изменения
@@ -0,0 +1,314 @@
# Реализация ручного учёта банковских карт
**Статус:** согласованный объём реализован и проверен 12 сентября 2026 года.
Независимое ревью завершено; change остаётся активным, без архивирования
и синхронизации в основные specs.
Основание — согласованные [Design revision 1](design.md), включая полностью
inline UI/UX package revision 1, и [Delta Specs](specs/manual-bank-cards/spec.md).
Внешних визуальных материалов или дополнительных пакетов для начала нет.
Checkbox закрывается только после выполнения действия и его Verification.
Verification ниже задаёт критерии; наблюдённые результаты отмечаются отдельно.
Перед Node-командами из affected repo выполняются
`source ~/.nvm/nvm.sh && nvm use --silent`; для specs используется frontend
`.nvmrc`. Git-команды выполняются из соответствующего независимого репозитория.
DB/HTTP-проверки используют уникальную временную SQLite с вымышленными данными
и явно заданной `DATABASE_URL`, без доступа к пользовательской базе или T-Bank.
Общий `npm run test:e2e` с фиксированной `/tmp/wealth-ledger-e2e.db` для них
не используется. Рабочая финансовая база не сбрасывается и не заменяется
ради выполнения проверки.
Задачи одного раздела выполняются по указанным зависимостям. Задача 1.2
может идти независимо от 1.1; остальные параллельные работы допустимы только
при закрытых зависимостях и раздельном владении изменяемыми файлами.
## 1. Основа хранения и точных денег
- [x] 1.1 [backend] Подтвердить применимость выбранного Solution Assessment: существующие Prisma/SQLite и migration boundary под Node 22; зафиксировать исходную версию схемы/приложения для проверки перехода и отката.
- **Covers:** `N/A` — техническое предусловие нового долговременного хранения из Design, без самостоятельного пользовательского поведения или смены выбранной архитектуры.
- **Verification:** `npx prisma validate`; существующие migrations применяются через `npx prisma migrate deploy` с `DATABASE_URL` каждой уникальной временной базы. Для снятия известного blocker нужны три последовательных успешных fresh-DB применения согласно [правилу проверки](../../../../docs/development/verification.md#известное-ограничение-сквозной-проверки). При ошибке сохранить версию и точный failure, не закрывать задачу и не подменять migration proof через `db push`, reset или ручной обход migrations.
- **Результат 2026-09-12:** Node `22.21.0`, Prisma/client `7.9.1`, engine `e922089b7d7502aff4249d5da3420f6fa55fc6ad`; `prisma validate` — passed. Исходный backend `8ec172f48eefa2237dc0c16919fbb2e726b39a65`, frontend `427ca23a433a20be25b1f4373f32c9e1f6f8c400`; оба working tree были чистыми. Обычный fresh deploy воспроизвёл `Error: Schema engine error:` без деталей, в том числе вне sandbox. С `RUST_LOG=info` штатный `npx prisma migrate deploy` вне sandbox прошёл три раза подряд: `/tmp/wealthledger-bank-cards-migrations-logging.rFEHKw/fresh-{1,2,3}.db`, логи `migrate-{1,2,3}.log` рядом. В каждом применены обе исходные migrations. Это подтверждает путь с указанной переменной окружения; запуск без неё остаётся ограничением. Версии зависимостей, применённые migrations и пользовательская база не менялись.
- [x] 1.2 [backend] Выделить общего владельца decimal parsing/formatting и MoneyDto, сохранить protobuf-преобразование у T-Bank и добавить точное преобразование допустимого RUB-ввода в копейки.
- **Covers:** `manual-bank-cards` — `Точные денежные значения и проверка до сохранения`.
- **Verification:** существующие decimal и затронутые брокерские calculation tests проходят до и после переноса; targeted Jest проверяет signed-64-bit границы, отрицательный/нулевой начальный остаток, строго положительную сумму операции, запрет лишней точности и точный Money roundtrip. `npm run typecheck:app`; прежний Money wire contract сохраняется.
- **Результат 2026-09-12:** общий owner `src/common/decimal.ts`, `money.dto.ts`, `rub-money.ts`; protobuf-преобразование осталось в T-Bank. До переноса `npm test -- --runInBand src/tbank` — 24 suites / 191 tests passed; после `npm test -- --runInBand 'src/tbank|src/common/rub-money'` — 25 suites / 213 tests passed. Targeted ESLint, `npm run typecheck:app`, `git diff --check` — passed. `npm run test:openapi-contract` до добавления банковских endpoints подтвердил неизменность прежнего контракта, но проверка обнаружила безусловную замену внешнего `DATABASE_URL` в `test/e2e-env.cjs` на фиксированную временную базу. Этот запуск не принимается как доказательство требуемой изоляции; после исправления test setup сравнение повторяется на уникальной базе. Пользовательская база не использовалась. Новый banking API требует отдельного финального сравнения.
- [x] 1.3 [backend] После 1.1 добавить BankCard, ExpenseCategory и BankCardOperation, additive migration, связи, ограничения уникальности, индексы, версии, tombstones и fingerprints из Design.
- **Covers:** `manual-bank-cards` — `Сохранённый ручной учёт переживает повторный вход и перезапуск`; `manual-bank-cards` — `Точные денежные значения и проверка до сохранения`.
- **Verification:** `npx prisma validate`, `npx prisma generate`, `npm run typecheck:app`; migration на изолированной базе с representative User/Session/TbankConnection сохраняет их данные. Реальная DB-проверка подтверждает FK/unique constraints и точное хранение копеек больше `2^53` и обеих граничных величин; применённые migrations не переписаны.
Если для устранения подтверждённого migration blocker потребуется другой
подход или изменение согласованного стека, это возвращает решение в Design.
Успех unit/Swagger-проверки не разрешает закрыть 1.1 или 1.3 без DB-доказательства.
Результат 1.3: additive migration `20260912090000_add_manual_bank_cards`
(существующие migrations неизменны), `prisma validate/generate` и app typecheck
— passed. Реальный adapter/Prisma roundtrip подтвердил `9007199254740993n`
и обе signed64 границы; configured DB-тесты подтвердили FK/unique.
Переход с исходной схемы сохранил representative User/Session/TbankConnection
в проверке сохранности 3.4.
## 2. Backend: пользовательский учёт и денежные изменения
- [x] 2.1 [backend] После 1.2–1.3 создать BankCardsModule и сервис сценариев: добавление карт с защитой повторов, пользовательские категории, начальный набор и переименование с сохранением идентичности и default role.
- **Covers:** `manual-bank-cards` — `Ручное добавление независимых банковских карт`; `manual-bank-cards` — `Общие для пользователя категории расходов`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`.
- **Verification:** targeted service/DB cases проверяют ограничения сведений карты, собственника, atomic card+seed creation, concurrent first-card creation без дублей категорий, регистронезависимую уникальность, rename default category и same-intent create карты с тем же/другим содержанием. Чтение не создаёт данных и не обращается к T-Bank.
- [x] 2.2 [backend] После 1.2 реализовать чистые правила календарных дат и точные расчёты баланса и месячных расходов из первичных записей, сохранив O(N) подход Design.
- **Covers:** `manual-bank-cards` — `Даты начала учёта и ручных операций`; `manual-bank-cards` — `Баланс определяется начальным остатком и внесёнными операциями`; `manual-bank-cards` — `Месячный журнал и сводка расходов по категориям`.
- **Verification:** targeted Jest проверяет `1 − 0,10 − 0,20 = 0,70`, отрицательный баланс, итог выше предела отдельного ввода, московскую границу дня/месяца, реальные календарные даты, возврат прошлого месяца и исключение переводов/пополнений из расходов. Расчёт не зависит от страницы журнала; комментарий `ponytail:` фиксирует предел O(N) и условие оптимизации.
- [x] 2.3 [backend] После 2.1–2.2 реализовать создание расходов и пополнений с проверкой карты/категории/дат, транзакцией и разрешением сохранённого намерения до повторной проверки изменяемых условий.
- **Covers:** `manual-bank-cards` — `Ручное внесение расходов и пополнений`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`.
- **Verification:** service/DB cases проверяют default category, сохранение при отрицательном остатке, отказ чужим ссылкам и недопустимому вводу без частичной записи; same UUID не создаёт дубликат, другой payload даёт конфликт, две независимые успешные траты сохраняются обе.
- [x] 2.4 [backend] После 2.3 реализовать перевод одной записью с проверкой обеих карт и дат начала учёта, двумя проекциями истории и общим денежным эффектом.
- **Covers:** `manual-bank-cards` — `Перевод между своими картами как одно действие`; `manual-bank-cards` — `Даты начала учёта и ручных операций`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`.
- **Verification:** реальная DB-проверка подтверждает обе стороны перевода, допустимый отрицательный остаток источника и отсутствие частичного результата при ошибке; отклоняются одинаковые, чужие и архивные карты и дата до начала учёта любой стороны. Сумма не включается в расходы одной или всех карт.
- [x] 2.5 [backend] После 2.3 реализовать связанные частичные и повторные возвраты, наследование категории покупки, общий лимит и чтение доступных исходных расходов за любой период.
- **Covers:** `manual-bank-cards` — `Возврат связан с исходным расходом`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`.
- **Verification:** DB cases проверяют несколько возвратов, исходный расход прошлого месяца, чужую/удалённую покупку, дату до покупки и два конкурирующих возврата с превышением общего лимита. Повтор успешно сохранённого полного возврата после потерянного ответа возвращает существующий результат без ошибки нового лимита и без дубликата.
- [x] 2.6 [backend] После 2.4–2.5 реализовать version-protected исправление и логическое удаление всех видов операций с проверкой прежних и новых связей, сохранением creation fingerprint и запретом изменения вида/исходной покупки возврата.
- **Covers:** `manual-bank-cards` — `Исправление и удаление операций с согласованным пересчётом`; `manual-bank-cards` — `Возврат связан с исходным расходом`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`.
- **Verification:** DB cases покрывают перенос расхода/пополнения между картами, смену получателя и удаление целого перевода, смену месяца/категории, ограничения исходного расхода с возвратами, edit refund с исключением собственной прежней суммы, stale edit/delete и повтор delete. Late create retry после edit/delete не возвращает старые суммы и не воскрешает запись; гонка refund с уменьшением покупки сохраняет общий лимит.
- [x] 2.7 [backend] После 2.6 реализовать изменение сведений и начала учёта карты, архив и возврат из архива с проверкой всей затронутой истории и version conflict.
- **Covers:** `manual-bank-cards` — `Ручное добавление независимых банковских карт`; `manual-bank-cards` — `Исправление начала ручного учёта`; `manual-bank-cards` — `Архив карт сохраняет историю`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`.
- **Verification:** DB cases проверяют opening balance без новой операции/изменения месячных трат, недопустимое новое начало с входящим переводом, архив при любом остатке, запрет create/edit/delete по архиву, unarchive и stale card edit. Гонка архива или смены даты с финансовым изменением даёт последовательный допустимый результат; повтор create архивированной карты возвращает ту же архивную карту.
Результат раздела 2: `src/bank-cards/bank-card.calculations.spec.ts` — 23
pure tests passed; полный backend gate — 33 suites / 253 tests passed.
`test/bank-cards.e2e-spec.ts` — 18 configured HTTP/SQLite tests passed
(`/tmp/bank-cards-http.log`): атомарные повторы/переводы, независимые траты,
конкурирующие возвраты и уменьшение покупки, смена начала/архив одновременно
с операцией, версии, foreign ownership, исправления/удаления, категории и
точные денежные границы. Fixtures вымышленные, URL уникальный; setup сохраняет
внешний `DATABASE_URL`, банковский suite запрещает shared/user DB.
## 3. Backend: чтение, HTTP и доказательство сохранности
- [x] 3.1 [backend] После раздела 2 подключить расчёты к согласованным чтениям списка/деталей карт, месячного журнала, исходных расходов возврата и сводок одной/всех карт.
- **Covers:** `manual-bank-cards` — `Баланс определяется начальным остатком и внесёнными операциями`; `manual-bank-cards` — `Месячный журнал и сводка расходов по категориям`; `manual-bank-cards` — `Архив карт сохраняет историю`; `manual-bank-cards` — `Возврат связан с исходным расходом`.
- **Verification:** integration cases проверяют пагинацию и стабильный порядок равных дат, весь набор для баланса/сводки, отрицательный net, архив в общей сводке, перенос между месяцами и перегруппировку связанных возвратов после изменения категории покупки. Связанные данные одного ответа согласованы; отсутствуют server cache и materialized balance.
- [x] 3.2 [backend] После 3.1 завершить controller/runtime DTO под `/api/v1/bank-cards`, подключить существующие JWT/filter и сформировать полный runtime OpenAPI для frontend.
- **Covers:** `manual-bank-cards` — `Отдельный раздел ручного учёта банковских карт`; `manual-bank-cards` — `Точные денежные значения и проверка до сохранения`; `manual-bank-cards` — `Даты начала учёта и ручных операций`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`.
- **Verification:** configured HTTP cases проверяют фактические validation/status/error codes, отсутствие сессии, чужие связи, архив/version/refund conflicts, безопасность ответов и работу без T-Bank. Runtime OpenAPI отражает действующие DTO и Money; сырые Prisma records не выдаются вместо оговорённого контракта, финансовые DTO не попадают в ответы с ошибками.
- [x] 3.3 [backend] После 3.2 завершить runnable configured HTTP/DB проверки критических сценариев и конкурентных изменений в отдельной временной базе, дополнив уже созданные cases вместо их дублирования.
- **Covers:** `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`; `manual-bank-cards` — `Исправление и удаление операций с согласованным пересчётом`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** с уникальной `DATABASE_URL` выполнить `NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules npx jest --config ./test/jest-e2e.json --runInBand bank-cards`. Проверяемые исходы: один результат concurrent same-intent create карты/операции, сохранность обеих независимых трат, no partial transfer/refund, stale conflicts, архивные/чужие ссылки, loss-of-response replay и отсутствие ложного успеха. Mock Prisma не заменяет DB cases.
- [x] 3.4 [backend] После 3.3 доказать долговременность ручных данных, корректный backup/restore и совместимость отката приложения с расширенной схемой без удаления новой истории.
- **Covers:** `manual-bank-cards` — `Сохранённый ручной учёт переживает повторный вход и перезапуск`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`.
- **Verification:** на disposable базах перезапуск сохраняет карты, операции, связи, переименованные категории, архив, tombstones и fingerprints; повтор после перезапуска не создаёт дубликат. Проверяются согласованный SQLite backup/restore, сохранность User/Session/TbankConnection, запуск исходной версии приложения с additive schema и доступность новых записей после возврата новой версии. Старый backup не восстанавливается поверх новой истории как штатный rollback.
Результат раздела 3: те же 18 HTTP/DB tests подтверждают чтения, пагинацию,
month/category regrouping, validation/status и безопасные ошибки без T-Bank.
Дополнительный процессный сценарий `node /tmp/bank-cards-durability.cjs` — passed:
исходный backend собран из Git archive `8ec172f48eefa2237dc0c16919fbb2e726b39a65`
в `/tmp/wealthledger-bank-baseline.8hcDz3`; миграция сохранила старые таблицы,
перезапуск — категории/архив/возвраты/tombstones/fingerprints, старое приложение
запустилось с расширенной схемой, новое вновь прочитало историю, согласованный
SQLite backup восстановлен и прочитан приложением. Свидетельство:
`/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-hPvsEO/result.json`.
Runnable версия сохранена в `backend/test/bank-cards-durability.cjs`; ей
передаётся каталог отдельно собранной исходной версии. Проверки создают свои
временные базы; старый backup не записывается поверх новой истории.
## 4. Контракт и слой данных frontend
- [x] 4.1 [frontend] После 3.2 обновить committed OpenAPI snapshot из настроенного backend runtime document и сгенерировать frontend types, сохранив backend единственным editable HTTP source.
- **Covers:** `N/A` — производные артефакты двустороннего контракта из Design обеспечивают типизированного потребителя, но не вводят отдельного пользовательского поведения.
- **Verification:** `npm run openapi:generate`, `npm run openapi:check`, `npm run typecheck`; snapshot получен по [существующему workflow](../../../../docs/development/runtime-openapi-verification.md), generated types не исправлялись вручную, существующие инвестиционные Money-контракты сохранены.
- [x] 4.2 [frontend] После 4.1 добавить banking queries/mutations у domain owner: session-aware transport, стабильный create UUID, version inputs, безопасные retries и invalidation всех затронутых банковских GET-представлений.
- **Covers:** `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`; `manual-bank-cards` — `Изоляция пользовательских финансовых данных`; `manual-bank-cards` — `Отдельный раздел ручного учёта банковских карт`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** Vitest/MSW через `npm run test:run -- src/entities/bank-card` проверяют одинаковый UUID при network/auth retry, отсутствие optimistic balance, invalidation обеих карт/всех месяцев/исходных расходов, rename категории, сохранение данных при ошибке, GET после version conflict, logout isolation и независимость от T-Bank connection mutation. Свой refresh loop и второй query cache отсутствуют.
Результат 4.2: `src/entities/bank-card/api/banking.test.tsx` — 10 MSW tests
passed; typecheck, focused Biome и dependency boundaries — passed. Повторы
сохраняют UUID, подтверждённые mutations инвалидируют только все banking GETs,
конфликты вызывают перечитывание, ошибки не меняют суммы оптимистически.
Дополнены реальные feature tests AuthProvider/TbankConnectionSection: 10 tests
passed доказывают очистку banking cache при logout/refresh failure и его
сохранение без invalidation при connect/replace/delete T-Bank.
Результат 4.1: snapshot экспортирован из configured runtime Nest/Swagger
(42 paths) на уникальной временной SQLite, включая банковские 409/410.
`npm run openapi:generate`, `npm run openapi:check`, `npm run typecheck` — passed;
generated types не редактировались вручную. После финального добавления
описаний 409/410 snapshot/types повторно сгенерированы, `openapi:check` passed.
## 5. Экраны и формы из UI/UX package revision 1
Все задачи раздела используют существующие Mantine-тему, i18n, container/VM/view,
RHF/Zod и shared UI из Design. Проверки формы и состояний выполняются через
видимый результат и сеть MSW; отдельный тест декларативной обёртки не нужен.
- [x] 5.1 [frontend] После 4.2 собрать `/cards`: навигацию, список активных карт и архива, месячную сводку всех карт и форму добавления карты с начальным остатком/датой.
- **Covers:** `manual-bank-cards` — `Отдельный раздел ручного учёта банковских карт`; `manual-bank-cards` — `Ручное добавление независимых банковских карт`; `manual-bank-cards` — `Месячный журнал и сводка расходов по категориям`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** `npm run test:run -- src/pages/cards` и targeted route tests проверяют login/returnTo без T-Bank, обязательные/необязательные поля, точный отрицательный остаток, no request при invalid, блокировку pending, сохранение ввода при ошибке и один create UUID при повторе. Loaded/empty/error/skeleton и архивный переключатель соответствуют Design; сводка всех карт включает архив независимо от фильтра списка.
- [x] 5.2 [frontend] После 5.1 собрать `/cards/:id`: крошки и title, текущий баланс, общий выбор месяца для журнала/сводки, пагинацию и представления четырёх видов операций.
- **Covers:** `manual-bank-cards` — `Баланс определяется начальным остатком и внесёнными операциями`; `manual-bank-cards` — `Месячный журнал и сводка расходов по категориям`; `manual-bank-cards` — `Архив карт сохраняет историю`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** `npm run test:run -- src/pages/card` проверяет month/URL и страницу, неизменный текущий баланс, расходы/возвраты/net со знаком, понятные стороны перевода, локальную ошибку сводки, cached snapshot при refetch failure и безопасное состояние отсутствующей карты. Frontend форматирует Money без собственной финансовой арифметики.
- [x] 5.3 [frontend] После 5.2 добавить формы создания расхода, пополнения и перевода с общими проверками денежного ввода/дат и сохранением одного намерения до определённого результата.
- **Covers:** `manual-bank-cards` — `Ручное внесение расходов и пополнений`; `manual-bank-cards` — `Перевод между своими картами как одно действие`; `manual-bank-cards` — `Точные денежные значения и проверка до сохранения`; `manual-bank-cards` — `Даты начала учёта и ручных операций`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** RTL/MSW cases проверяют default category, положительную сумму/копейки без потери больших значений, даты обеих карт перевода, отрицательный итог без запрета реальной траты, visible labels, pending/ошибки с сохранённым вводом и повтор одного перевода без дубликата. Новое подтверждённое состояние приходит из банковских queries.
- [x] 5.4 [frontend] После 5.3 добавить создание и редактирование возврата с выбором исходной покупки за всю историю карты и корректным лимитом текущей записи.
- **Covers:** `manual-bank-cards` — `Возврат связан с исходным расходом`; `manual-bank-cards` — `Исправление и удаление операций с согласованным пересчётом`; `manual-bank-cards` — `Месячный журнал и сводка расходов по категориям`; `manual-bank-cards` — `Согласованные изменения и защита повторной отправки`.
- **Verification:** RTL/MSW journey выбирает покупку прошлого месяца, записывает частичный возврат и видит его в месяце получения; полный возврат позволяет исправить описание, исключая собственную старую сумму из лимита. Проверяются превышение, stale source, immutable purchase link, архив, network retry полного возврата и сохранение формы без ложного успеха.
- [x] 5.5 [frontend] После 5.3–5.4 добавить остальные исправления/удаления операций, изменение сведений и начала учёта карты, архив и возврат из архива с понятными конфликтами.
- **Covers:** `manual-bank-cards` — `Исправление и удаление операций с согласованным пересчётом`; `manual-bank-cards` — `Исправление начала ручного учёта`; `manual-bank-cards` — `Архив карт сохраняет историю`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** RTL/MSW проверяет edit с version и последующим GET при конфликте, сохранение черновика, перенос между картами/месяцами, подтверждение удаления перевода с обеими картами, запреты связанного расхода и архива, opening date error и обратимый архив. После успешного действия все затронутые видимые суммы соответствуют новым серверным данным.
- [x] 5.6 [frontend] После 5.3 добавить управление начальными и своими категориями и создание категории из формы расхода без потери введённой операции.
- **Covers:** `manual-bank-cards` — `Общие для пользователя категории расходов`; `manual-bank-cards` — `Ручное внесение расходов и пополнений`; `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`.
- **Verification:** RTL/MSW проверяет add/rename, уникальность без регистра и длину, переименование default category без утраты роли, обновление названий в истории/сводке и неизменный черновик расхода при добавлении категории. Удаление и вложенные подкатегории отсутствуют согласно scope.
Результат 5.1–5.6: реализованы страницы, четыре вида операций, исправления,
удаление, архив и категории. RTL/MSW покрывают точный ввод и UUID после ошибки,
month/page/returnTo, неизменный текущий баланс, полный возврат с собственным
лимитом, stale version с явным принятием новой версии, opening-date conflict,
архив, обе стороны удаления перевода и вложенное создание категории.
Финальный frontend gate включает 43 test files / 263 passed tests.
Браузер обнаружил потерю нового имени категории после reset неконтролируемого
поля: поле переведено на RHF Controller, исправление проверено RTL и настоящим
PATCH/GET. Независимое ревью обнаружило скрытые ошибки вспомогательных GET
карт/категорий: добавлены локальные сообщения и retry, две regression-проверки
и browser recovery passed. Существующие authentication/инвестиционные tests
прошли; returnTo сохраняет карточку и query выбранного месяца.
## 6. Проверка пользовательского результата и документация
- [x] 6.1 [frontend] После 3.3–3.4 и разделов 4–5 проверить UI package в браузере с изолированным backend и подготовить Storybook-примеры новых собственных представлений на вымышленных данных.
- **Covers:** `manual-bank-cards` — `Доступные формы и различимые состояния без ложного успеха`; `manual-bank-cards` — `Отдельный раздел ручного учёта банковских карт`; `manual-bank-cards` — `Сохранённый ручной учёт переживает повторный вход и перезапуск`.
- **Verification:** реальный UI journey без T-Bank: две карты → расход/пополнение → перевод → возврат прошлой покупки → исправление/удаление → архив/unarchive → reload/login. Проверить широкий экран и 320–375 px, пять пунктов навигации, длинные суммы/названия, keyboard/focus return, reduced motion и loading/empty/error/refetch recovery. Сохранить краткие результаты и локальные визуальные свидетельства; Storybook build не подменяет браузерную проверку.
- [x] 6.2 [docs] После подтверждения реализованного поведения обновить документацию: область банковских карт, глоссарий, обзор продукта и тематическую навигацию, хранимые данные, денежные правила и безопасное восстановление.
- **Covers:** `N/A` — as-built объяснение поставленного поведения и восстановления из Delivery Map; новая норма не вводится и не копируется параллельно Delta Specs.
- **Verification:** пройти путь README → банковские карты → понятия/расчёты/исправления и восстановление; сверить текст с поставленными flows и ограничениями, включая ручной источник баланса, отрицательные итоги, архив, повторы и сохранение новой истории при rollback. Ссылки ведут на существующие targets; исторические материалы не переписываются, обещания неподтверждённой миграции не добавляются.
Результат 6.1: реальный браузер работал с отдельным BrowserContext и backend
на уникальной временной SQLite, с вымышленным пользователем без T-Bank.
Через UI выполнены две карты (1 000 ₽ и 0 ₽), августовский расход 100 ₽,
сентябрьские пополнение 200 ₽, перевод 150 ₽ и возврат 60 ₽: баланс A 1 010 ₽,
B 150 ₽, сентябрьский net −60 ₽. Возврат исправлен до полного 100 ₽,
его описание изменено, затем сумма возвращена к 60 ₽. Исправление покупки
100 → 150 ₽ дало A 960 ₽; удаление перевода с подтверждением обеих карт —
A 1 110 ₽ и B 0 ₽. Архив сохранил историю и общий net; unarchive вернул действия.
Добавление категории из расхода сохранило сумму 12,34 ₽ и описание черновика;
переименование «Другое» сохранило default role. Проверены Escape и возврат
фокуса к действию, visible labels, initial loading/empty, ошибки GET и retry,
сохранённый net при неудачном refetch, reduced motion. Logout → protected URL →
login вернул ту же карту и август; reload сохранил баланс 1 110 ₽ и расход 150 ₽.
Экран 1440 px и 320/375 px проверен визуально: пять пунктов навигации,
длинное название и точная предельная сумма доступны без горизонтального overflow.
Локальные screenshots итогового UI:
`/tmp/wealthledger-bank-cards-final-desktop.png`,
`/tmp/wealthledger-bank-cards-final-mobile.png`,
`/tmp/wealthledger-bank-cards-final-mobile-long.png`,
`/tmp/wealthledger-bank-card-final-mobile.png`;
состояние cached error — `/tmp/wealthledger-bank-cards-cached-error.png`.
Добавлены две изолированные story groups и 9 примеров на вымышленных данных;
storybook dependency isolation и build passed.
Результат 6.2: обновлены README/overview, glossary, область cards, описание
хранения, локальная миграция/восстановление и UI-каталог. Маршруты README → cards →
Money/восстановление проверены; объяснения сверены с Delta Specs, реализацией
и наблюдёнными flows. Новых Mermaid-диаграмм и изменений исторических ADR нет.
## 7. Финальные gates и состояние SDD-артефактов
Backend `npm run verify` — passed (`/tmp/bank-cards-verify.log`): lint без
warnings, app/test TypeScript, 33 suites / 253 tests, Prisma generate и build.
Последующий итоговый HTTP-прогон и independent review описаны ниже;
успех gates не является заменой semantic review.
После независимого review исправлен P2 в DTO: явный `null` для Money,
архива и non-null полей отклоняется; nullable реквизиты очищаются по-прежнему.
Configured HTTP/SQLite — 19/19 passed, включая 400 без изменения данных/версий;
OpenAPI parity на уникальной базе — passed, wire shape не изменился.
Итоговый `npm run verify` повторно passed
(`/tmp/bank-cards-final-backend-verify.log`). Сохранённый runnable durability
сценарий повторно passed на итоговом backend:
`/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-dXSTmz/result.json`.
Reviewer перепроверил исправление и не нашёл других backend findings.
Gate применяется к итоговому состоянию соответствующего repo. Уже прошедшие
проверки не повторяются без новых изменений, сбоев или оставшегося риска.
Если финальная правка меняет проверенную границу, повторяется её нужный gate.
- [x] 7.1 [backend] После разделов 1–3 выполнить canonical backend gate и сохранить результаты DB/HTTP/durability проверок для того же состояния кода.
- **Covers:** `N/A` — обязательная проверка backend delivery из Design, дополняющая уже покрытые поведенческие и DB cases без новой возможности.
- **Verification:** `npm run verify`; отдельные результаты 3.3–3.4 остаются применимы к итоговому коду. Type/lint/unit/build не объявляются доказательством транзакций или миграции, если DB-проверка не запускалась успешно.
- [x] 7.2 [frontend] После 6.1 выполнить canonical frontend gate для итоговых API types, UI, i18n, stories и dependency boundaries.
- **Covers:** `N/A` — обязательный frontend delivery gate из Design, без добавления самостоятельного пользовательского поведения.
- **Verification:** `npm run verify` включает OpenAPI drift, typecheck, lint, coverage, Knip, dependency boundaries и обе сборки. Отдельный результат браузерной проверки 6.1 сохраняет применимость; процент покрытия и Storybook build не заменяют его.
- [x] 7.3 [workspace] После 4.1, 7.1 и 7.2 подтвердить двусторонний runtime OpenAPI contract по итоговому backend и frontend snapshot/types.
- **Covers:** `N/A` — межрепозиторная verification из Contract Gate Decision, без владения кодом приложений или отдельным поведением.
- **Verification:** из backend выполнить `npm run test:openapi-contract`; использовать актуальный frontend `openapi:check` из 7.2, не повторяя его при неизменном snapshot/types. Проверка не требует T-Bank и не изменяет snapshot автоматически.
- [x] 7.4 [docs] После 6.2 выполнить canonical docs gate, ручную смысловую сверку и проверку затронутых маршрутов чтения.
- **Covers:** `N/A` — проверка связности as-built документации из Delivery Map, без самостоятельного нормативного поведения.
- **Verification:** из docs выполнить `npm run verify`; проверить новые Markdown-файлы и затронутые внешние относительно docs ссылки. Если изменены Mermaid-диаграммы, отдельно проверить доступным локальным renderer их вид либо явно сохранить ограничение; новый стек для этого не устанавливать.
- [x] 7.5 [specs] После всех implementation-owned задач сверить поставленный результат со всеми Delta Requirements и обновить этот Tasks только по выполненным действиям и наблюдённым проверкам, затем выполнить canonical validation.
- **Covers:** `N/A` — поддержание существующих SDD-артефактов и traceability реализации из Delivery Map; новые требования, архитектура и отдельный журнал доказательств не создаются.
- **Verification:** каждое требование `manual-bank-cards` связано с выполненной задачей и подходящим доказательством; все оставшиеся ограничения названы. Выполнить `openspec validate add-manual-bank-cards --type change --strict --store wealth-ledger-specs`, `openspec validate --specs --strict --store wealth-ledger-specs` и `git diff --check`, отдельно просмотрев новые файлы и ссылки. Approval и завершение реализации не выводятся только из наличия файлов.
Независимая проверка результата и завершение lifecycle выполняются после
implementation-owned задач; они не маскируются checkbox, который исполнитель
не может закрыть сам. Этот план не включает преждевременную синхронизацию
нового capability в основные specs, архивирование или изменение истории.
Такие действия относятся к последующему завершению выбранного SDD lifecycle.
Итоговые результаты 7.2–7.4: frontend `npm run verify` — passed
(`/tmp/bank-cards-final-frontend-verify.log`): OpenAPI drift, TypeScript,
Biome, 43 files / 263 tests с coverage, Knip, обе dependency проверки,
app и Storybook builds. Backend `npm run test:openapi-contract` — passed
на уникальной базе (`/tmp/bank-cards-contract.log`); последний DTO fix
не меняет wire shape, frontend snapshot/types после этого не изменялись.
Docs `npm run verify` — passed, 54 Markdown-файла, 0 issues.
Независимое ревью backend/frontend/docs завершено: найденные null-validation,
category-form и auxiliary-query recovery дефекты исправлены и перепроверены;
открытых actionable findings нет. Обычный Prisma fresh migrate deploy без
`RUST_LOG=info` по-прежнему имеет указанное в 1.1 ограничение; причина
не установлена. Подтверждён только штатный путь с переменной окружения.
Предупреждение Storybook/Vite о размере chunks не препятствует сборке.
Результат 7.5: все 17 Delta Requirements сверены с реализацией и Covers/проверками
выше; 29 implementation-owned задач завершены. `openspec validate
add-manual-bank-cards --type change --strict --store wealth-ledger-specs` — valid;
`openspec validate --specs --strict --store wealth-ledger-specs` — 25 passed,
0 failed. Итоговый routing повторён по modified/untracked файлам четырёх repo;
новые файлы и ссылки просмотрены, `git diff --check` — passed в каждом repo.
Proposal, Delta Specs, Design, основные specs и архивы не изменялись.
Последующие проверки перед commit: по запросу пользователя все четыре нативных
поля дат/месяца заменены на Mantine DateInput/MonthPickerInput. Сохранение
календарных дат, month/URL и календарь на 320 px проверены в браузере
на новой временной базе; будущие даты недоступны. Полный frontend
`npm run verify` повторно passed: 43 files / 263 tests и обе сборки
(`/tmp/bank-mantine-verify.log`).
После отдельного разрешения пользователя миграция применена к рабочей SQLite
через `RUST_LOG=info npx prisma migrate deploy`. До применения создан
согласованный SQLite backup; после — проверены integrity/FK и неизменность
User/Session/TbankConnection. Это обновление рабочей среды, не тестовые записи
в пользовательской базе. Backup исключён из Git; ограничение обычного запуска
Prisma без `RUST_LOG=info` остаётся прежним.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-09-16
@@ -0,0 +1,31 @@
# Completion Evidence: containerized-application-delivery
## Outcome
The local and disposable single-server container delivery path is implemented
and verified. External publication and target-server gates remain unavailable in
the current environment and are recorded as blockers rather than represented as
successful results.
## Observed Results
- Frontend and backend development/production images build and run locally.
- Development HMR/watch, same-origin API routing, production SPA fallback,
readiness, persistence, migration failure handling, backup and isolated
restore were verified on disposable data.
- Docs verification and Compose configuration checks passed.
- No production data, secrets, registry credentials or real server were used.
## External Blockers
The following approved implementation tasks remain externally blocked:
- Docker-capable Gitea runner acceptance;
- registry publisher and server pull-only credentials;
- authenticated candidate publication and digest pull;
- target-server Docker/Compose verification;
- first server rollout pair confirmation.
These tasks remain unchecked in the archived checklist. The change is archived
as externally blocked after owner authorization; this is not a claim that the
external gates passed and does not authorize production rollout.
@@ -0,0 +1,247 @@
## Context
Frontend и backend выпускаются из независимых Git-репозиториев. Frontend —
Vite SPA, backend — NestJS API с embedded SQLite, Prisma migrations, native
Node dependencies и runtime `.proto` contracts. Браузерная сессия использует
Bearer access token и Secure httpOnly refresh-cookie с path `/api/v1/auth`.
На production-сервере уже работает Caddy. Он должен знать только домен и один
loopback port WealthLedger; TLS и server-wide routing остаются вне приложения.
Краткий простой обновляемого компонента допустим. Текущие кэши, rate limiter и
connection generation допускают только один активный backend instance.
Root workspace связывает независимые checkout, но его CI по действующему
контракту выполняет только commitlint. Поэтому orchestration принадлежит root,
а сборка и публикация каждого image — репозиторию компонента.
## Goals / Non-Goals
**Goals:**
- запускать весь application runtime локально через Docker с Linux, общей
container network, единым browser origin и быстрым frontend/backend watch;
- поставлять отдельные immutable frontend и backend images;
- публиковать наружу только frontend port, через который доступны SPA и API;
- хранить SQLite и backend secrets вне images и сохранять их при recreation;
- применять Prisma migrations до готовности новой версии backend;
- обновлять frontend или backend отдельно с явной парой установленных версий;
- дать оператору воспроизводимые backup, rollback и restore boundaries.
**Non-Goals:**
- zero-downtime, blue-green, несколько backend replicas и высокая доступность;
- PostgreSQL, Kubernetes, container orchestration вне одного сервера;
- управление Caddy, DNS, TLS, host firewall или provisioning сервера;
- изменение HTTP API, session semantics или пользовательского интерфейса;
- общий release номер для независимых репозиториев.
## Decisions
Уточнение Change Owner от 2026-09-16: обязательная настройка digests отменена.
Production принимает `FRONTEND_IMAGE`/`BACKEND_IMAGE` с release version tags.
Публикация сохраняет запрет перезаписи release tags; `latest` не используется.
### 1. Docker Compose владеет application topology
Root workspace получает production Compose и development override. Production
Compose использует опубликованные image references; development override
заменяет их local development targets, подключает исходники и отдельное local
data storage. Новые custom orchestration scripts не создаются: lifecycle
выражается штатными Compose commands и документированной последовательностью.
Production содержит два long-running services:
- `frontend`: nginx со статической SPA и proxy `/api/` в `backend:3000`;
- `backend`: один NestJS process с доступом к persistent SQLite storage.
Одноразовый migration service использует тот же backend image и data storage,
завершается до старта новой версии backend. Наружу публикуется только nginx на
настраиваемый loopback host port. Caddy проксирует весь домен на этот port и не
маршрутизирует `/api/` отдельно.
Development сохраняет те же component и network boundaries, но frontend target
запускает Vite с proxy `/api/` в `backend:3000`, а backend target запускает Nest
watch. Browser видит только frontend port. Source mounts не включают host
`node_modules`; Linux dependencies принадлежат images/volumes. Изменение
manifest или lockfile требует rebuild соответствующего development image.
Отдельный nginx gateway в development не добавляется: он создал бы третий
постоянный service, но всё равно использовал бы отличающуюся proxy-конфигурацию
для Vite HMR. Production image и его nginx routing проверяются отдельным
production Compose smoke scenario.
### 2. Frontend и backend имеют собственные multi-stage images
Frontend image собирает SPA точной project Node version и переносит только
build output и nginx configuration в version-pinned nginx runtime. Production
SPA использует same-origin API base. nginx поддерживает SPA history fallback,
передаёт `/api/` без переписывания prefix и повторно разрешает Docker DNS имя
backend после recreation.
Backend image собирается под Linux точной project Node version. Runtime содержит
compiled application, production dependencies, generated Prisma client,
migrations и T-Bank `.proto` contracts по пути, который использует production
process. Prisma CLI становится явным runtime tool backend image; test, lint и
compiler tooling в runtime не переносится. Process работает не от root и пишет
только в выделенный data path.
Оба images получают OCI metadata с repository revision и version. Base images
и package installs закрепляются воспроизводимыми версиями; новая platform или
architecture не обещается без отдельной проверки.
### 3. Gitea Container Registry хранит независимые release images
Frontend и backend workflows публикуют собственный image после push защищённого
release tag `vX.Y.Z`. Publish job повторяет канонический repository gate,
проверяет совпадение tag с package version и только затем отправляет semver tags в Gitea Container Registry. `latest` не используется.
Server deployment фиксирует полные frontend/backend image references с
semver tags; оператор выбирает опубликованную версию каждого компонента. CI использует отдельный secret с
package write permission, сервер — отдельный credential только для pull. Ни один
credential не хранится в repository или image. Возможность встроенного Gitea
workflow token публиковать packages не предполагается без проверки фактической
server version.
Root не получает image workflow: каждый repository самостоятельно доказывает и
публикует свой artifact, а Compose только фиксирует установленную пару image references.
### 4. Same-origin routing сохраняет действующую auth boundary
Production frontend nginx и development Vite направляют исходный `/api/...` в
backend без изменения path. Frontend client использует relative API base.
Backend получает `FRONTEND_ORIGIN`, равный browser-visible origin; production
работает с `NODE_ENV=production`, поэтому refresh-cookie остаётся Secure.
nginx передаёт host, forwarding headers и request ID context, не журналирует
Authorization, Cookie или request body. Backend port доступен только Compose
network. Server host публикует frontend на loopback, чтобы Caddy оставался
единственным внешним listener.
### 5. Readiness опирается на существующий runtime contract
Migration service должен завершиться успешно до запуска backend. Backend
healthcheck подтверждает завершённый HTTP-ответ от существующего auth route;
статус `401` без cookie является допустимым доказательством готового HTTP/auth
boundary. Новый публичный health endpoint не добавляется. Frontend healthcheck
получает статический root и зависит от healthy backend при полном старте.
Compose `--wait` используется как bounded readiness gate. Он не является
автоматическим rollback и не обещает отсутствие краткого `502` во время
отдельного backend update или недоступность всей точки входа во время frontend
recreation.
### 6. SQLite остаётся single-server storage в явно ограниченной роли
Production использует один backend process и один persistent host data
directory. Это расширяет прежнее локальное решение ADR 001: docs получают новый
ADR, который разрешает SQLite только для текущего single-server deployment и
фиксирует условия пересмотра — несколько replicas, иной availability target,
рост write contention или потребность в отдельном database service.
Перед migration backend останавливается и создаётся согласованная копия всего
data directory. Остановка исключает конкурентные writes и позволяет сохранить
database вместе с возможными WAL companion files без нового backup service.
Encryption key хранится отдельно от data backup; восстановление требует оба
артефакта.
Local development использует другой named volume/data directory и другие
secrets. Production data никогда не монтируется в development environment.
### 7. Обновления меняют только выбранный service
Frontend update меняет только frontend image reference, загружает image и
recreates frontend без зависимостей. Backend и data остаются запущенными, но
единая внешняя точка входа кратко недоступна во время recreation.
Backend update сначала сохраняет backup при остановленном backend, затем меняет
backend image reference. Migration service и backend используют один reference; backend
стартует только после успешной migration и становится доступен после healthcheck.
Frontend не пересоздаётся. nginx повторно разрешает service DNS после смены
backend container address.
Release compatibility остаётся обязанностью API evolution: новая сторона
ставится первой только когда прежняя сторона совместима с ней. Compose-файл
фиксирует пару image references, но не вводит второй version negotiation mechanism.
## UI/UX Applicability and Package
**Applicability and scope:** `N/A` — change меняет development, packaging и
server operations, но не экраны, состояния, interactions или responsive
поведение WealthLedger. Новый визуальный пакет не требуется.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Multi-stage development/production image, same-origin client configuration, Vite API proxy, nginx SPA/API routing и image publication workflow. |
| backend | `affected` | Multi-stage development/production image, runtime Prisma migration assets, container readiness и image publication workflow. |
| specs | `affected` | Новая capability `application-delivery` и утверждаемый SDD package. |
| docs | `affected` | Deployment/runbook, обновлённая container topology и ADR ограниченного single-server SQLite deployment. |
Root workspace дополнительно владеет Compose orchestration и кратким entrypoint
README как cross-area coordination; это не переносит component build ownership
из frontend/backend и не добавляет root CI помимо commitlint.
## Solution Assessment
| Вариант | Fit и зрелость | Security / operations | Ограничения и migration cost | Решение |
| --- | --- | --- | --- | --- |
| Docker Compose + два component images | Нативно покрывает один server, service DNS, healthchecks, volumes и targeted recreation; широко поддерживается | Малый operating surface; registry credentials и host Docker требуют обычной защиты | Краткий простой, один host, нет automatic rollback | Выбран как минимальное полное решение |
| Один объединённый application image | Простая установка одного artifact | Один lifecycle и один process boundary проще оператору | Связывает независимые релизы, пересобирает неизменённый component, усложняет watch и нарушает repository ownership | Отклонён |
| Kubernetes или Swarm | Зрелая orchestration для replicas и rolling updates | Добавляет control plane, политики и постоянную эксплуатацию | Не нужен одному server и не решает текущие SQLite/multi-instance ограничения | Отклонён до появления HA требований |
| Caddy маршрутизирует отдельно frontend и backend | Использует существующий server proxy | Убирает internal gateway layer | Server должен знать внутреннюю topology, появляются два application upstream и deployment перестаёт быть self-contained | Отклонён по выбранной внешней границе |
| Отдельный постоянный gateway service внутри Compose | Стабильный port при frontend recreation | Может уменьшить внешний простой frontend update | Третий runtime service и конфигурация без требования zero-downtime | Отклонён; пересмотреть при SLO без краткого простоя |
| SQLite на persistent directory | Уже реализован, зрел для single-process embedded workload, не требует database service | Минимальная сеть и credentials; backup требует остановки | Один writer/process topology, нет HA; новая ADR boundary обязательна | Выбран в ограниченной single-server роли |
| PostgreSQL service | Зрел для concurrency, replicas и независимого DB lifecycle | Требует отдельные credentials, backup, upgrades и monitoring | Миграция Prisma provider и существующих данных вне scope; текущая нагрузка не требует | Отклонён до появления multi-instance/availability требования |
| Собственная packaging/orchestration layer | Может точно повторить частные команды | Создаёт собственный security и maintenance surface | Дублирует Compose, registry и standard image lifecycle | Отклонён |
Gitea Container Registry и Actions используются как уже доступные mature
platform capabilities. Предварительное условие Apply — runner с Docker build/push
capability и package permissions; при отсутствии этого условия Design не
заменяется самописным registry uploader, а publish task остаётся blocked.
## Risks / Trade-offs
- Frontend recreation кратко отключает и SPA, и API ingress; это принято
текущим non-goal zero-downtime.
- Backend update может кратко возвращать `502`; healthcheck ограничивает окно,
но не устраняет его.
- Старые browser tabs могут запрашивать chunks прежнего frontend. nginx задаёт
revalidation для HTML и immutable cache для hashed assets, но прежние assets
после recreation не сохраняются. Пользователю может потребоваться reload;
автоматическое восстановление старых вкладок остаётся вне текущего scope.
- SQLite на server расширяет прежний local-only baseline. Новый ADR делает это
осознанным ограничением, а не молчаливым изменением production guarantee.
- Воспроизводимость version tags зависит от запрета их перезаписи: workflow
отклоняет существующий tag. Переназначать опубликованные tags нельзя.
- Development работает через Vite, production — через nginx. Production Compose
smoke проверяет различающийся web-server boundary до поставки.
- Полная runtime image воспроизводимость зависит от base-image availability;
digests и сохранённые release images уменьшают, но не устраняют registry risk.
## Migration Plan
1. Утвердить SDD package и новый ADR о границе SQLite на одном сервере.
2. Подготовить component images и проверить их отдельно с каноническими gates.
3. Добавить root Compose topology и development override; подтвердить watch,
same-origin API, отсутствие внешнего backend port и изоляцию local data.
4. Добавить release-tag publication в component repositories и получить version tags
проверенных images из Gitea Registry.
5. На server создать restricted environment/secrets и data directories,
сохранить исходную базу и encryption key, затем выполнить первый migration
service и production smoke через loopback port.
6. Настроить Caddy на единственный loopback port и проверить login, refresh,
чтение существующих данных и один внешний financial request.
7. Зафиксировать установленную пару image references и удалить прежний runtime
только после успешной проверки нового.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Reason | Change вводит server persistence, migrations, secrets и новую delivery boundary. |
| Trigger | Migration failure, unhealthy component, невозможность login/refresh, нечитаемое encrypted connection, повреждение данных или критическая регрессия smoke scenario. |
| Actions | Не направлять Caddy на первый deployment либо вернуть предыдущий component image reference. Старую версию backend запускать только после проверки совместимости с текущей schema. Если schema несовместима, остановить приложение и отдельно предложить восстановление согласованного backup; не выполнять restore автоматически. |
| Data safety | Backup создаётся при остановленном backend из всего data directory; encryption key сохраняется отдельно. Данные, созданные после backup, считаются более новыми и не заменяются без явного решения оператора о потере этого интервала. |
| Verification | Проверить component health, schema migration status, login/refresh, чтение существующих user/card data, decrypt/use сохранённого T-Bank connection и отсутствие повторной migration ошибки; затем записать фактически установленную пару image references. |
@@ -0,0 +1,46 @@
## Why
WealthLedger сейчас запускается как набор локальных процессов, а поставка на
сервер не определена. Разработка не воспроизводит границы runtime-среды, а
оператор не имеет согласованного способа установить, обновить, сохранить или
восстановить приложение.
Нужен единый поддерживаемый путь запуска для локальной разработки и одного
сервера, чтобы поставка каждой версии была воспроизводимой и не требовала
ручного пересоздания окружения.
## What Changes
Будет определён поддерживаемый способ запускать WealthLedger локально и на
одном сервере. Внешняя серверная маршрутизация передаёт весь трафик
приложения на одну точку входа WealthLedger; приложение само обслуживает
интерфейс и API.
Оператор сможет независимо обновлять интерфейс или API согласованными
версиями поставки. Пользовательские данные, сессии и секреты сохраняются
между штатными перезапусками и обновлениями. Для обновления и восстановления
будут определены необходимые безопасные действия и их границы.
Не входят непрерывное обновление без краткого простоя, несколько экземпляров
API, высокая доступность, смена хранилища данных, изменение внешнего reverse
proxy и управление серверной инфраструктурой вне WealthLedger.
## Capabilities
### New Capabilities
- `application-delivery`: поддерживаемый локальный и серверный запуск,
обновление компонентов и сохранность состояния WealthLedger.
### Modified Capabilities
Нет.
## Impact
Пользователь получает одинаково работающую среду разработки и поставленное
приложение. Оператор получает воспроизводимую установку, независимое
обновление частей приложения и понятные границы восстановления данных.
Изменение затронет frontend, backend, specs, docs и общую orchestration
workspace; детали решений и delivery будут определены в последующих
утверждаемых артефактах.
@@ -0,0 +1,105 @@
## Purpose
Определяет поддерживаемые эксплуатационные свойства локального и серверного
запуска WealthLedger, включая поставку компонентов и сохранность состояния.
## ADDED Requirements
### Requirement: Локальная среда воспроизводит границы поставленного приложения
Поддерживаемый локальный запуск SHALL предоставлять один browser origin для
интерфейса и API WealthLedger. Разработка интерфейса MUST сохранять быстрый
цикл обратной связи при изменении исходного кода, а разработка API MUST
перезапускать затронутый runtime без ручной пересборки всей среды. Локальные
данные MUST быть изолированы от server data.
#### Scenario: Разработчик открывает локальное приложение
- **WHEN** поддерживаемая локальная среда успешно запущена
- **THEN** браузер получает интерфейс и обращается к API через тот же origin,
не зная внутреннего адреса API
#### Scenario: Разработчик меняет интерфейс
- **WHEN** разработчик изменяет исходный код интерфейса в запущенной локальной
среде
- **THEN** браузер получает изменение без ручной полной пересборки среды
### Requirement: Серверное приложение имеет одну внешнюю точку входа
Поддерживаемый server deployment SHALL принимать HTTP-трафик WealthLedger на
одной внешней точке входа. Через неё MUST быть доступны интерфейс и API с
сохранением их действующих путей и session semantics. API и хранилище данных
MUST NOT требовать отдельного внешнего сетевого доступа.
#### Scenario: Внешний reverse proxy направляет запрос приложению
- **WHEN** server reverse proxy передаёт запрос на единственную точку входа
WealthLedger
- **THEN** пользователь получает интерфейс, а запросы к действующему API
обрабатываются приложением без отдельного маршрута reverse proxy к API
### Requirement: Поставка допускает независимое обновление компонентов
Система SHALL предоставлять versioned delivery artifacts для интерфейса и API
как независимых компонентов. Оператор MUST иметь возможность установить
выбранную версию только одного компонента, сохранив другой компонент и
постоянное пользовательское состояние работающими. Поддерживаемая поставка
MUST явно фиксировать выбранные версии обоих компонентов.
#### Scenario: Обновляется только интерфейс
- **WHEN** оператор устанавливает новую версию интерфейса при неизменной
совместимой версии API
- **THEN** API и его постоянное состояние не пересоздаются, а приложение
начинает отдавать новую версию интерфейса
#### Scenario: Обновляется только API
- **WHEN** оператор устанавливает новую версию API при неизменной совместимой
версии интерфейса
- **THEN** интерфейс не пересоздаётся, а приложение начинает обрабатывать
запросы новой версией API после её готовности
### Requirement: Постоянное состояние и секреты переживают штатный lifecycle
Пользовательские данные, session state и ключи, необходимые для чтения
зашифрованных подключений, MUST сохраняться при штатных перезапусках и
обновлениях компонентов. Секреты MUST задаваться отдельно от versioned delivery
artifacts и MUST NOT попадать в интерфейс, журналы или публичный HTTP-ответ.
#### Scenario: Приложение штатно перезапускается
- **WHEN** оператор штатно перезапускает приложение без восстановления данных
- **THEN** после запуска доступны прежние пользовательские данные и действующие
session semantics, а сохранённые зашифрованные подключения остаются читаемыми
тем же ключом
### Requirement: Обновление API сохраняет целостность данных
Перед тем как новая версия API начинает принимать запросы, необходимые
изменения схемы постоянных данных MUST быть применены успешно. При ошибке
изменения данных новая версия API MUST NOT объявляться готовой. Краткий простой
обновляемого компонента допустим; непрерывность обслуживания и несколько
одновременных экземпляров API не являются обещанием этой capability.
#### Scenario: Подготовка данных новой версии не прошла
- **WHEN** обязательное изменение постоянных данных завершается ошибкой
- **THEN** новая версия API не начинает обслуживать запросы, а оператор получает
диагностируемый неуспешный результат обновления
### Requirement: Восстановление не выдаёт откат приложения за откат данных
Поддерживаемая процедура обновления и восстановления MUST различать возврат
версии компонентов и восстановление пользовательских данных. Она MUST требовать
согласованную резервную копию перед изменением постоянных данных и MUST NOT
заменять более новые пользовательские данные прежней копией как побочный эффект
возврата версии приложения.
#### Scenario: Оператор возвращает предыдущую версию API
- **WHEN** оператор возвращает прежнюю версию API после неуспешного обновления
- **THEN** процедура явно проверяет совместимость этой версии с текущими
данными и не выполняет восстановление старой копии данных без отдельного
решения оператора
@@ -0,0 +1,219 @@
# Контейнерная разработка и поставка WealthLedger
Основание — утверждённые [Proposal](proposal.md),
[Delta Specs](specs/application-delivery/spec.md) и [Design revision 1](design.md).
UI/UX package неприменим: пользовательские экраны и interactions не меняются.
Checkbox закрывается только после выполнения действия и его Verification.
Перед Node-командами активируется `.nvmrc` соответствующего независимого
репозитория. Git-команды выполняются из репозитория-владельца; production data,
secrets и опубликованные release tags не используются как test fixtures.
Задачи выполняются по порядку разделов. Frontend 2.1–2.2 и backend 3.1–3.2
могут идти параллельно после 1.2. Workspace orchestration начинается после
готовности обоих component images. Реальный server rollout выполняется только
после локального production smoke и документированного backup.
## 1. SDD и внешние prerequisites
- [x] 1.1 [specs] Проверить утверждённый SDD package закреплённым OpenSpec CLI и сохранить canonical change структурно валидным до Apply.
- **Covers:** `N/A` — обязательный gate canonical SDD authority; он не добавляет runtime behavior, но разрешает реализацию утверждённых требований и Design.
- **Verification:** с версией из `openspec/.cli-version` выполнить `openspec schema validate wealth-ledger-sdd --verbose`, `openspec validate containerized-application-delivery --type change --strict --store wealth-ledger-specs`, `openspec validate --specs --strict --store wealth-ledger-specs`, затем `npm run verify`.
- **Result (2026-09-16):** по явному уточнению владельца использован установленный OpenSpec 1.13.0 вместо 1.10.0. Все четыре проверки прошли; 25 specs passed, 0 failed. Repository-wide pin не изменён.
- [ ] 1.2 [workspace] Подтвердить prerequisites выбранного Solution Assessment: Docker Compose на development/server hosts, Docker-capable Gitea runner, Container Registry и раздельные package write/read credentials.
- **Covers:** `N/A` — техническое предусловие утверждённой Compose/Registry delivery; оно не вводит самостоятельное поведение приложения и блокирует custom fallback при отсутствии platform capability.
- **Verification:** зафиксировать поддерживаемые Docker/Compose и Gitea versions; `docker version`, `docker compose version`, успешные authenticated registry login для CI publisher и server pull-only identity без вывода credentials.
- **Partial result (2026-09-16):** локально Docker Desktop 4.91.0, Engine 29.8.0 (Linux arm64), Compose 5.5.1; Gitea API сообщает 1.27.3. Server Docker/Compose и Docker-capable runner пока не подтверждены. MCP identity не имеет доступа к Actions secrets (`user should be the owner of the repo`); раздельные publisher/pull-only login не проверены. Publication и server rollout остаются внешними gates; независимая локальная реализация продолжается.
## 2. Frontend image и ingress
- [x] 2.1 [frontend] Добавить development и production image targets: Dockerized Vite watch с same-origin `/api/` proxy и nginx runtime со SPA fallback, API proxy, runtime DNS re-resolution, cache policy и non-root-compatible filesystem.
- **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Серверное приложение имеет одну внешнюю точку входа`.
- **Verification:** собрать оба targets; development container через container network отдаёт HMR и сохраняет `/api/v1/...`; production container отдаёт SPA/deep link, проксирует API без rewrite, не раскрывает backend port и после backend recreation подключается к новому container address.
- [ ] 2.2 [frontend] Добавить release-tag workflow публикации frontend image с проверкой версии, canonical frontend gate, OCI revision metadata и immutable semver tags без `latest`.
- **Covers:** `application-delivery` — `Поставка допускает независимое обновление компонентов`.
- **Verification:** workflow syntax принимается Gitea; controlled candidate run после `npm run verify` публикует image, registry возвращает digest, clean pull по digest запускает тот же static artifact; credential отсутствует в logs и image history.
- [x] 2.3 [frontend] Выполнить frontend repository completion gate после packaging и routing changes.
- **Covers:** `N/A` — repository-owned проверка реализации Design и отсутствие regression в существующем frontend; отдельного observable requirement не создаёт.
- **Verification:** `npm ci`, `npm run verify`, `git diff --check`; вручную просмотреть новые Docker/nginx/workflow files и отсутствие secrets.
### Результаты frontend (2026-09-16)
- 2.1: оба targets собраны на Linux arm64; Vite HMR WebSocket connected,
`/api/v1/probe?check=path` сохранён. nginx read-only + tmpfs отдаёт deep link
с `no-cache`, proxy сохраняет path/query/host/proto/request ID. Backend без
host port пересоздан с другим IP (.2 → .5); nginx восстановил proxy без restart.
- 2.2: release-tag workflow реализован с `ubuntu-latest` (label подтверждён
владельцем). Реальный runner Docker access, registry write/read credentials,
Gitea workflow acceptance и controlled publication пока не проверены.
- 2.3: `npm ci`, `npm run verify`, `git diff --check` прошли. Docker context
ограничен allowlist; `.env`, credentials и host dependencies не копируются.
Новые Docker/nginx/workflow files просмотрены; publication secrets идут
только в отдельный Docker login, не в image build arguments.
## 3. Backend image, migration и readiness
- [x] 3.1 [backend] Добавить development и production image targets с Linux native dependencies, compiled application, generated Prisma client, runtime Prisma CLI/migrations, T-Bank proto assets, non-root process и выделенным data path.
- **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Постоянное состояние и секреты переживают штатный lifecycle`; `application-delivery` — `Обновление API сохраняет целостность данных`.
- **Verification:** development target выполняет Nest watch; production target стартует из чистого image, находит proto contracts, принимает config только из environment, пишет только в data path; одноразовый `prisma migrate deploy` проходит на fresh и representative existing SQLite, а приложение не стартует после контролируемой migration failure.
- [ ] 3.2 [backend] Добавить release-tag workflow публикации backend image с проверкой версии, полными backend gates, OCI revision metadata и immutable semver tags без `latest`.
- **Covers:** `application-delivery` — `Поставка допускает независимое обновление компонентов`.
- **Verification:** workflow syntax принимается Gitea; controlled candidate run выполняет `npm run verify` и `npm run test:e2e`, публикует image и получает digest; clean pull по digest выполняет migration и production start, credential отсутствует в logs и image history.
- [x] 3.3 [backend] Выполнить backend repository completion gates и проверить container readiness без нового публичного endpoint.
- **Covers:** `N/A` — repository-owned проверка packaging, existing auth boundary и SQLite integration из Design; самостоятельного нового API behavior нет.
- **Verification:** `npm ci`, `npm run verify`, `npm run test:e2e`, `git diff --check`; healthcheck принимает завершённый HTTP-ответ existing auth route, а stopped/uninitialized process остаётся unhealthy.
## 4. Workspace Compose orchestration
- [x] 4.1 [workspace] Добавить production Compose topology с version-tagged frontend/backend references, migration service, healthchecks, persistent host data directory, internal-only backend и единственным configurable frontend loopback port.
- **Covers:** `application-delivery` — `Серверное приложение имеет одну внешнюю точку входа`; `application-delivery` — `Поставка допускает независимое обновление компонентов`; `application-delivery` — `Постоянное состояние и секреты переживают штатный lifecycle`; `application-delivery` — `Обновление API сохраняет целостность данных`.
- **Verification:** `docker compose config` с non-secret fixture environment; rendered config содержит один host port, один backend replica, одинаковый backend image reference у migration/runtime и отдельный data mount; missing required image/config/secret values дают blocking configuration/start failure.
- [x] 4.2 [workspace] Добавить development Compose override с local image targets, source watch/mounts, container-owned dependencies, отдельным local data volume и одним frontend browser origin.
- **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Постоянное состояние и секреты переживают штатный lifecycle`.
- **Verification:** с чистыми local volumes запустить Compose; frontend edit появляется через HMR, backend edit перезапускает Nest, `/api/v1/...` идёт через frontend origin, host `node_modules` не используется, restart сохраняет local data, production data path отсутствует в rendered config.
- [x] 4.3 [workspace] Обновить root tracking allowlist и README entrypoints без custom scripts или нового root CI, затем проверить orchestration configuration.
- **Covers:** `N/A` — root только связывает независимые component repositories и объясняет стандартные Compose entrypoints согласно Design; runtime behavior реализуется component images и Compose tasks 4.1–4.2.
- **Verification:** `docker compose config` для production и development variants, `git diff --check`, просмотр `git status --short` и staged paths подтверждают только явно разрешённые root files; root workflow остаётся только commitlint.
## 5. Эксплуатационная документация
- [x] 5.1 [docs] Добавить ADR об ограниченном single-server SQLite deployment и обновить container architecture фактической Docker/Caddy topology, secret и persistence boundaries.
- **Covers:** `application-delivery` — `Серверное приложение имеет одну внешнюю точку входа`; `application-delivery` — `Постоянное состояние и секреты переживают штатный lifecycle`.
- **Verification:** ADR явно связывает решение с прежним local-only baseline, одним backend instance и triggers пересмотра; architecture diagram показывает только frontend port наружу, internal backend и persistent SQLite; ссылки и термины согласованы.
- [x] 5.2 [docs] Описать development, first deployment, independent component update, stopped-backend backup, compatibility check, rollback и explicit restore runbooks.
- **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Поставка допускает независимое обновление компонентов`; `application-delivery` — `Обновление API сохраняет целостность данных`; `application-delivery` — `Восстановление не выдаёт откат приложения за откат данных`.
- **Verification:** dry-run runbook на disposable data проходит без неописанного шага; frontend-only и backend-only paths не пересоздают другой component; restore требует отдельного решения и сообщает о потере данных после backup.
- [x] 5.3 [docs] Выполнить docs repository completion gate.
- **Covers:** `N/A` — repository-owned проверка durable as-built documentation и ссылок без нового runtime behavior.
- **Verification:** `npm ci`, `npm run verify`, `git diff --check`; проверить Mermaid rendering, взаимные ADR/architecture/runbook links и отсутствие credentials или host-private values.
## 6. Сквозная проверка поставки
- [x] 6.1 [workspace] Проверить development Compose на чистом checkout и production Compose на локально собранных release-equivalent images.
- **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Серверное приложение имеет одну внешнюю точку входа`.
- **Verification:** development подтверждает HMR/watch и same-origin login; production smoke подтверждает SPA deep link, registration/login/refresh, API proxy, отсутствие host backend port и clean startup через migration service.
- [x] 6.2 [workspace] На disposable representative SQLite проверить persistence, frontend-only update, backend-only update, migration failure и rollback boundaries.
- **Covers:** `application-delivery` — `Поставка допускает независимое обновление компонентов`; `application-delivery` — `Постоянное состояние и секреты переживают штатный lifecycle`; `application-delivery` — `Обновление API сохраняет целостность данных`; `application-delivery` — `Восстановление не выдаёт откат приложения за откат данных`.
- **Verification:** restart сохраняет user/session/card data и decryptable test connection; frontend update не recreates backend/data, backend update не recreates frontend; failed migration не делает backend healthy; возврат совместимого image сохраняет current data, а restore отдельной backup copy выполняется только в изолированном scenario с явной проверкой потерянного интервала.
- [ ] 6.3 [workspace] Проверить release images из Gitea по выбранным version tags перед первым server rollout и сохранить точную установленную пару версий.
- **Covers:** `application-delivery` — `Поставка допускает независимое обновление компонентов`; `application-delivery` — `Постоянное состояние и секреты переживают штатный lifecycle`.
- **Verification:** pull-only server identity получает оба versioned images; `docker image inspect` подтверждает expected revisions; production smoke проходит на этих images с disposable config/data, после чего оператор записывает выбранные image references без secrets.
## Apply verification — 2026-09-16
### Backend (3.1–3.3)
- Development и production targets собраны на Linux arm64 / Node 24.21.0.
Node/nginx base images закреплены digest; OS packages — точными версиями.
Чистый production runtime обнаружил неверный прежний fallback proto path:
Nest копирует assets в `dist/src`; loader теперь использует `__dirname`.
Nest watch использует `ps`; `procps` добавлен только в development target.
- `npm ci`, `npm run verify` — PASS (35 suites). `npm audit` при переносе
существующего Prisma 7.10.0 в runtime dependencies — 0 vulnerabilities.
- `npm run test:e2e` на macOS дважды завершился native Node SIGABRT
(`RemoveEnvironmentCleanupHook`, `env != nullptr`). Тот же штатный gate
выполнен в Linux development image: **8 suites / 98 tests PASS**. Это
Linux delivery proof; macOS native cleanup limitation не скрывается.
- `npm run test:container -- wealthledger-backend:production` — runnable
image regression: fresh/existing migration, runtime без source/.env/compiler,
non-root + read-only filesystem, существующий auth readiness, user/session
persistence. Он же вызывается publish workflow после clean pull.
- Controlled broken SQLite migration возвращает non-zero; backend остаётся
остановленным. После возвращения исправного data path migration идемпотентна.
- 3.2 остаётся открытой: YAML и shell syntax проверены локально, но Gitea
acceptance, authenticated publication и pull по registry digest не выполнены.
### Compose, docs и lifecycle (4–6)
- Production/development `docker compose config` — PASS. Проверены один
loopback host port, backend scale 1, общий backend image/data mount у
migration/runtime, readiness dependency и отсутствие production bind mount
в development services. Пустая production environment блокирует config.
- `docker compose up -d --wait` — PASS для development и production на
локально собранных release-equivalent images. Production smoke использовал
временный override local image references; canonical config остаётся
digest-pinned. Реальный Gitea digest pull этим не подменяется.
- Development: source edit вызвал HMR event и изменённый HTTP module, backend
source edit — новый Nest process и готовый API. Временные изменения
восстановлены. Same-origin registration/login/refresh и data restart PASS.
- Clean checkout proof: отдельный HEAD export + task diff, без host .env и
node_modules, новый Compose project/volume; startup и HMR/watch PASS.
- Browser proof (bundled Playwright/Chrome): clean development и production
registration/login/reload-refresh PASS; все API requests same-origin,
httpOnly/path сохранены, production cookie Secure. Caddy/TLS вне локальной проверки.
- Production: SPA deep link, registration/login/refresh, неизменённый API path,
отсутствие backend host port, non-root read-only runtime — PASS.
- Representative disposable SQLite содержит user/session/card и connection,
зашифрованный настоящим TbankCrypto на вымышленном token. Restart, migrations
и independent recreation сохраняют данные и расшифровку без внешнего T-Bank.
- Отдельные local fixture image IDs проверили frontend-only/backend-only
update без recreation другого компонента. Возврат прежнего совместимого
backend image сохраняет запись после backup. Это compatibility fixture,
не обещание совместимости произвольных будущих релизов.
- Stopped-backend backup и isolated restore с отдельным project/port/data path
— PASS: login, карта до backup и decryptable connection доступны; запись
после backup отсутствует в восстановленной копии. Текущая база не заменялась.
- Docs: ADR 015 расширяет ADR 001; topology и runbook взаимно связаны.
`npm ci`, `npm run verify` — PASS (44 Markdown files), `git diff --check`
— PASS. Mermaid source семантически просмотрен, но renderer в доступном
окружении не найден; **5.3 оставлена открытой до проверки rendering**.
- Root Boy Scout/allowlist review: только Compose, два безопасных env examples,
README и .gitignore; новых root scripts/dependencies/CI нет. Source markers
watch checks восстановлены. Чужие runtime data и checkout changes сохранены.
- Docker disk был заполнен. После адресной очистки intermediates этой задачи
и отдельно разрешённой очистки unused build cache старше суток проверки
продолжены; чужие images/containers/volumes не удалялись.
### Оставшиеся внешние gates
- 1.2: server Docker/Compose, Docker capability runner `ubuntu-latest`,
раздельные package writer и server pull-only authenticated login.
- 2.2/3.2: реальные Gitea candidate workflows и публикация frontend/backend,
OCI revisions, registry digests, clean pull и отсутствие credentials в logs.
Нужны Actions secrets `REGISTRY_USERNAME`/`REGISTRY_TOKEN` в каждом component
repo и отдельная server identity. Workflow не публикует `latest` и fail-closed
отказывается от overwrite или недоказанного отсутствия tag.
- 6.3: smoke опубликованной пары version tags на disposable server config/data,
запись installed image references/revisions до первого server rollout.
Server rollout, Caddy, TLS и реальный финансовый запрос не выполнялись.
### Упрощение deployment configuration по запросу владельца
Обязательные `FRONTEND_DIGEST`/`BACKEND_DIGEST` и отдельные repository fields
заменены на `FRONTEND_IMAGE`/`BACKEND_IMAGE` с release version tags.
Design и runbook синхронизированы. Результаты выше описывают первоначальную
проверку; обязательный digest pinning более не является deployment contract.
Migration и backend по-прежнему используют один image reference.
Verification упрощения: production/development `docker compose config` PASS;
разные version tags выбираются независимо, migration/runtime получают один
backend image, один loopback port и development volume сохранены. Пустые
image/secret values блокируют config. Docs `npm run verify`, OpenSpec strict
change validation и `git diff --check` в affected repos — PASS.
По следующему запросу владельца из `FROM` удалены base image digests:
используются `node:24.21.0-bookworm-slim` и `nginx:1.28.0-alpine`.
Version tags не гарантируют неизменность содержимого базового image.
Verification: повторная сборка production/development targets обоих компонентов
— PASS (с использованием Docker cache). Dockerfile остаются в component repos,
чтобы standalone build и release CI не зависели от root checkout.
По запросу владельца удалены дополнительные commit tags из обоих workflows;
release tag защищён от перезаписи, revision остаётся в OCI label. Production
Compose наследует healthchecks из images; development содержит собственные
checks с прежними таймингами, migration отключает check.
Verification: production/development Compose config и invariants, healthchecks
локальных production images, shell syntax обоих workflows, docs verify,
OpenSpec strict validation и diff checks — PASS. Registry publication остаётся
внешней непроверенной частью задач 2.2/3.2.
@@ -0,0 +1 @@
schema: wealth-ledger-sdd
@@ -0,0 +1,440 @@
## Context
Локальная архитектура реализована; независимая публикация отложена. Baseline исследования — frontend `3c99deb`, версия `0.3.0`,
2026-09-20. Требования — в
[proposal](proposal.md) и [delta](specs/frontend-design-system/spec.md).
В приложении уже работают React, TypeScript, Vite, Mantine, TanStack Query,
RHF/Zod, JSON-токены, общий адаптер темы, Storybook, Vitest/RTL/MSW,
dependency-cruiser и Knip. Они сохраняются. Другие production-потребители
общего UI или клиентского SDK в исследованном workspace не обнаружены.
На 2026-09-22 workspace-пакеты `@wealthledger/tokens`, `@wealthledger/ui` и
`@wealthledger/icons` используются локально как private packages. Это принятая
часть перехода. Описанные далее независимый release и Nexus остаются
предложением, отложенным до оценки пилота. Фактическое выполнение и ограничения
сохраняются в [tasks](tasks.md).
## Goals / Non-Goals
**Goals:** три небольшие границы поставки общего фундамента; единый каталог
источников; сохранение внешнего вида и предметных инвариантов; потребление
собранного пакета; ограниченные задачи, исполнимые по одной сессии.
**Non-Goals:** другой UI framework, второй transport/store/form stack,
универсальные CRUD/table/form engines, монорепозиторий всего WealthLedger,
mobile adapter, тёмная тема, массовый rewrite CSS и тестирование внутренностей
Mantine. Календарь платежей требует отдельного продуктового запроса.
## Decisions
### 1. Репозиторий и направления зависимостей
Рекомендация: **npm workspaces внутри существующего frontend**. Корневое
приложение сохраняет `src`, Vite entry, собственные providers и `private: true`.
Новые пакеты располагаются в `packages/tokens`, `packages/icons`, `packages/ui`.
Имена `@wealthledger/*` приняты для локальных packages; регистрация scope
и внешняя registry-поставка не подтверждены.
| Вариант | Польза | Цена в этом проекте | Решение |
| --- | --- | --- | --- |
| Три workspace-пакета в frontend | Один lockfile, toolchain, Storybook и атомарный review источников с потребителем | Нужно явно расширить scopes проверок; локальный link может скрыть дефект поставки | Выбран; обязательна проверка packed consumer |
| Один отдельный design-system repo с тремя пакетами | Отдельный owner, release cadence, независимые потребители | Новый CI/catalog/install, синхронизация PR и доступа к registry при одном приложении | Отложить до второго независимого production consumer либо отдельного владельца/release cadence |
| Три отдельных Git repo | Полностью независимые права/жизненные циклы | Три workflow и совместимость без подтверждённой выгоды | Не выбирать |
| Только существующий shared | Уже работает, нет стоимости публикации | Не даёт запрошенной границы версионируемой поставки | Сохраняется до готовности package foundations, не конечная цель |
Направление стрелки — «потребляет»; циклов нет:
```text
frontend app / единственный Storybook
├─ @wealthledger/ui ── @wealthledger/tokens
│ └─ Mantine + React (peers у ui)
├─ @wealthledger/icons ── React (peer)
└─ entities/features/pages → shared/api, domain models, i18n
tokens → нет runtime dependencies
ui → нет icons dependency до появления реального общего consumer
icons → нет ui/tokens/Mantine/router/API dependencies
```
Общие компоненты принимают уже подготовленные строки и slots. `EntryCard`
принимает `ReactNode` и не знает, какой пакет рисует icon/action. Domain выбирает
цвет по смыслу и форматирует деньги; UI только отображает переданное значение.
### 2. Публичная поверхность и сборка
Имена ниже в колонке «Цель» — принятые публичные exports локальных пакетов.
Внутренние `src`, `dist/*` и application alias `@/` не являются package API.
| Пакет | Цель / entrypoints | Состав поставки | Зависимости |
| --- | --- | --- | --- |
| tokens | `@wealthledger/tokens/tokens.json` | Доверенный JSON v1; JS, CSS, React-адаптер и `.d.ts` на старте не нужны | Нет runtime dependencies |
| icons | Именованные `AddIcon`, `EditIcon`, `DeleteIcon` из `@wealthledger/icons` | ESM JS + declarations; CSS только если переносится собственная анимация | React peer + external; React DOM не используется |
| ui | `@wealthledger/ui`: `StateView`, `LoadingSkeleton`, `EntryCard` и их необходимые props types; `@wealthledger/ui/theme`: `theme`, `colorRoles`, `cssVariablesResolver`; `@wealthledger/ui/styles.css` | ESM JS + declarations + собранный CSS, включая CSS Modules | React/React DOM и используемые Mantine core/hooks — peers/external; tokens — точная dependency |
`designTokens` нужен foundations story: адаптер может сохранить существующий
одноимённый export из `ui/theme`, который ссылается на JSON, а не копирует его.
JSON остаётся отдельно доступным без Mantine. Не добавлять JS-обёртку в tokens
только ради импорта JSON. Не выдавать non-web adapter за готовый.
Использовать установленный Vite для library build ui/icons и TypeScript для
declarations. Один `tsc` недостаточен для CSS Modules `EntryCard`. Формат — ESM;
CJS/SSR build не нужен текущему Vite SPA. Настройки library build ещё предстоит
проверить на зафиксированной Vite 8.3.0; готовых конфигураций здесь нет.
Публикуемые `files`/`exports` ограничивают поверхность. CSS явно включается в
side effects; JS импорт сам не устанавливает provider и не читает приложение.
Library build использует отдельную конфигурацию нужных plugins, не наследует
app Vite config целиком: PWA/manifest/service worker и dev API proxy не должны
попасть в поставку библиотеки.
Exports указывают на собранные artifacts, поэтому обычные local/CI entrypoints
сначала собирают packages, затем выполняют consumer typecheck/tests/build.
Сейчас `verify` начинает проверки до app build; этот порядок потребуется
дополнить при 2.2. Acceptance начинается на чистом checkout после `npm ci`,
без старого package dist/cache. Достаточно последовательности npm scripts,
без собственного build orchestrator.
Docker — отдельный потребитель workspace: нынешний `Dockerfile` копирует только
root manifests перед `npm ci`, а `.dockerignore` не допускает packages.
В 2.1–2.2 нужны точечные manifests/source/build-config allowlists, установка
с workspace manifests и package build перед consumer. Host node_modules,
готовый локальный dist и реальные `.env` в контекст не попадают. Development
и production targets проверяются отдельно. В текущем Compose монтируются
только app src/public: изменения packages сначала требуют rebuild dev image.
Отдельный package bind-mount/watch в Compose отложен до фактической потребности;
host development может использовать package watch без изменения root repo.
Сначала peer ranges узкие, по проверенной комбинации React 19.3.0 и Mantine
9.6.1; расширять их только после проверки потребителя. React/React DOM
devDependencies для разработки не должны попадать в bundle. Проверять
`npm ls react react-dom @mantine/core` и содержимое bundle/packed consumer;
`dedupe` не заменяет корректную упаковку. Две несовместимые версии Mantine
также нельзя скрыть локальным symlink.
### 3. Токены: использовать уже существующий JSON
В [tokens.json](../../../../frontend/packages/tokens/tokens.json) уже есть
переносимые значения и описания. **Не менять формат v1 ради упаковки.**
`primitive → semantic → component` — модель владения и ссылок, а не повод
немедленно переименовать все ключи в новый стандарт.
| Уровень | Текущая основа и целевое правило |
| --- | --- |
| Primitive | `color.base`, `color.palettes`, `typography`, `spacing.values`, `radius.values`, `shadows.values`, `breakpoints.values`; исходные эффективные значения сохраняются |
| Semantic | Существующие `color.roles.*` с `color` + `shade` ссылаются на primitive; `primary` с `shade: null` сохраняет разрешение через primaryShade |
| Component | Пока новые не нужны. Добавлять только именованный совместно управляемый контракт реального общего компонента, если semantic role недостаточно; ссылаться на semantic, затем primitive при отсутствии смысловой роли |
Имена описывают роль (`surface`, `border`, `positive`), а не экран или случайный
цвет. Ссылки однонаправленные и разрешимые; обратных ссылок из primitive на
semantic/component нет. Новый color role включает назначение и ограничение
применения. Семантический положительный цвет не вычисляет знак финансовой суммы.
Сохранить единицы: размеры/тени в px адаптируются существующими `rem`/`rgba`,
breakpoints в em не масштабируются, line-height без единицы. Имеющиеся
[theme tests](../../../../frontend/packages/ui/src/theme.test.ts) защищают
именно преобразования и ссылки, не каждое значение JSON.
Общие CSS-роли `--wl-color-*` производит прежний `CSSVariablesResolver`.
Spacing/radius/type scales продолжают поступать в Mantine theme и его CSS
variables. Статическая генерация второго `tokens.css` не нужна: появился бы
ещё один адаптер и риск расхождения scale. JSON экспортируется без сборочного
token engine; Style Dictionary, DTCG-converter и runtime schema отложены до
конкретного внешнего потребителя/формата. Источник остаётся доверенным файлом,
не внешним пользовательским документом.
Светлая схема сохраняется. `primaryShade.dark` не означает готовую dark theme.
Бизнес-коды, суммы/валюты, московские даты, pagination limits, brand gradients
`BankCardFace` и случайная геометрия SVG не превращаются в глобальные токены.
Первое применение semantic roles — точные эквиваленты gray-3/gray-0/green-6/
red-6 в `EntryCard`; перенос и эта правка идут отдельно.
### 4. Mantine, тема и providers
`Stack`, `Group`, `Button`, `Paper`, `ActionIcon`, поля, Modal, Dates/Charts
остаются прямыми импортами Mantine. Изменение defaults решается темой.
Wrapper нужен только собственному контракту: busy region у `LoadingSkeleton`,
empty/error composition у `StateView`, layout/image fallback/actions у `EntryCard`.
App владеет MantineProvider, DatesProvider, QueryClientProvider, AuthProvider,
Router и i18n. Storybook имеет свой Mantine/Dates/i18n runtime с той же темой.
UI не создаёт `WealthLedgerProvider`, не импортирует локализацию продукта и
не включает app providers. `ui/styles.css` содержит существующую визуальную
основу и стили общих компонентов; app/preview подключают его явно после
Mantine CSS. Продуктовые route transitions и bank-card styles остаются в app.
Theme Modal transition и глобальная CSS animation сейчас сосуществуют.
Их визуальный конфликт не доказан. После выделения выбрать один механизм
открытия по browser comparison, предпочтительно уже настроенный Mantine
transition; нельзя удалить второй механизм на основании одного сходства кода.
### 5. Иконки и движение
Начальный набор — реально повторяющиеся plus/pencil/trash из Card/Cards views.
Имена `AddIcon`, `EditIcon`, `DeleteIcon` — предлагаемая поверхность, не найденные
exports. Размер задаёт `size` (default 24), геометрия сохраняет `viewBox`, цвет
получается через `currentColor`. Существующие размеры и strokeWidth 1.5/2
сохраняются у callers; выравнивание рисунка не совмещается с переносом.
Канонический исходник SVG для небольшого набора — **SVG-разметка в именованных
React-компонентах** `packages/icons/src/svg/<Name>.tsx`. Это один редактируемый
источник, без параллельных ручных `.svg` и `.tsx`. Отдельные raw SVG-файлы в
первой поставке не публикуются: non-React consumer пока отсутствует. Если
потребуются исходные `.svg` как самостоятельный продуктовый артефакт, перенести
канон в `src/svg/*.svg` и сравнить штатный SVGR CLI с ручным поддержанием;
не писать собственный генератор. Сейчас ни dependency, ни pipeline для этого
не нужны. JS/types несут SVG-компоненты, это явно отражено в составе поставки.
По умолчанию рисунок декоративный (`aria-hidden`, без фокуса). У standalone
meaningful icon потребитель задаёт доступное имя и image semantics. У
`ActionIcon` имя остаётся на действии; SVG не дублирует его. Общий пакет не
выбирает подпись «редактировать операцию» и не получает DTO.
`BankOperationIcon` и `operation-appearance.ts` остаются в entity: mapping
category/kind → рисунок/цвет/анимация — бизнес-логика. Собственная геометрия и
движение рисунка могут перейти в icons позже при реальном reuse. Hover/focus
trigger по сценарию, route transitions, bank/card state остаются приложению.
Существующий `bind-icon-animation` переиспользуется, если переносится тот же
animation contract; он не копируется в пакет ради трёх статичных иконок.
Все переносимые движения обязаны сохранять reduced-motion поведение.
### 6. Каталог источников компонентов
Порядок поиска: **существующие компоненты приложения → собственная библиотека
→ Mantine → уже разрешённые dependencies → обоснованное новое решение**.
Новый компонент появляется у владельца реальной ответственности, а не потому,
что текущий компонент отсутствует в Storybook. Таблица — компактный маршрут,
не каталог каждого JSX-узла. Номера обозначают этапы в [tasks](tasks.md).
| Назначение | Текущий источник | Целевой источник | Правило применения | Этап |
| --- | --- | --- | --- | --- |
| Layout, buttons, inputs, dialogs | `@mantine/core`, `@mantine/dates` | Те же пакеты | Прямые imports; variant/default через theme, не WL-копия каждого primitive | 1, 2 |
| Цветовые роли, шкалы | `shared/theme/tokens.json` | `tokens/tokens.json` + адаптер `ui/theme` | JSON — значения, адаптер — Mantine, бизнес-смысл — consumer | 2 |
| Тема/общие стили | `shared/theme` | `ui/theme`, `ui/styles.css` | Одна тема App/Storybook; providers у runtimes | 2 |
| Busy skeleton region | `shared/ui/LoadingSkeleton` | `ui` | Label и region общие, skeleton shapes принадлежат странице | 3.1 |
| Empty/error | `shared/ui/StateView` | `ui` | Строки/action props; query/error classification вне UI | 3.1 |
| Строка с изображением и значением | `shared/ui/EntryCard` | `ui` | Готовые strings/slots, fallback и responsive actions; без money parsing | 3.2–3.3 |
| Plus/edit/delete | Inline SVG Card/Cards views | `icons` | Named imports; label и disabled у ActionIcon | 4 |
| Навигация и active route | `shared/ui/AppShell` | `app/ui` | Product shell; не публичный компонент ui-пакета | 1.1 |
| Карта и её операции | `entities/bank-card` | Там же | `BankCardFace`, `BankOperationIcon`, `BankSummaryView`, `BankCardFields`, `BankFeedback` — domain | Без переноса |
| Период банковских данных | `entities/bank-card/BankPeriodPicker`, model/period | Там же | Draft/URL/query semantics сохраняются; не универсальный date service | 5, без extraction |
| Период дохода | `pages/account-income/.../IncomePeriodForm.view` | Та же page; pure helpers в её model | Изолировать story от VM, сохранить validation/submit | 5.3 |
| Позиции/структура портфеля | `pages/account/ui/components/PositionCard.view`, `PortfolioStructure.view` | Там же | Page-local композиция допустима; не переделывать под EntryCard | Без переноса |
| Недавние события | `entities/brokerage-event` + Account page | Там же; общий EntryCard из ui | Первый продуктовый пилот, финансовое представление у entity | 3.2 |
| Общие instrument details | `features/instrument-details` | Там же | Реальный reuse stocks/bonds; typed presentation contract отдельно от общего UI | Без переноса |
Последние непакетные источники выбираются через существующие `index.ts`.
В таблице обозначения `shared/ui/...` описывают файлы до миграции; это не разрешение
на deep import. Текущие владельцы указаны в колонке цели.
### 7. Предметный UI, API и конечные словари
Рекомендация: оставить domain UI в `entities/features/pages`. Второй
производственный consumer, которому нужен тот же стабильный domain contract,
и отдельный release owner — проверяемое условие рассмотрения business package.
Storybook не считается таким потребителем. Повторная карточка может потребовать
переноса page → entity внутри приложения, но не нового npm-пакета.
`shared/api` сохраняет snapshot, generated types, typed transport и auth-aware
boundary. Генерируются **типы**, а не весь handwritten client. Query keys,
normalization, freshness и invalidation остаются в entity/feature owner;
не входят ни в generated artifact, ни в ui. Отдельный `api-client` сейчас
потребовал бы отделить Vite environment, auth/retry lifecycle и query policy без
второго потребителя. Вернуться к вопросу при втором независимом приложении.
Backend остаётся единственным editable HTTP source. Frontend получает
предоставленный snapshot, локально генерирует и сравнивает declarations;
соседний backend checkout не становится входом CI. DTO aliases из generated
`paths` сохраняются. UI form models допустимы, когда отличаются от transport.
Для frontend собственных конечных словарей — принятый `as const` + union:
`enum` несовместим с текущим `erasableSyntaxOnly`. Набор operation kinds можно
свести у bank entity и проверить через generated `BankOperation['kind']`;
не объявлять вторую DTO enum. Wire literals сохраняются. `ProblemDetailsDto.code`
остаётся `string`, unknown code получает fallback; нельзя объявлять закрытый
список ошибок. Общий `apiErrorCode` извлекает code, одинаковое recovery
классифицируется ближайшим допустимым owner без обратных FSD imports.
### 8. Политика библиотек
Версии закреплены в frontend package manifests и lockfile. Решения ниже
не означают новой установки или обновления.
| Область | Сейчас | Решение и причина | Этап |
| --- | --- | --- | --- |
| Build/React/Router | Vite, TS, React Compiler, React Router | Сохранить; расширить имеющийся build для library artifacts | 2 |
| Server/local state | TanStack Query / React state / URL | Сохранить owners; Redux из transitive deps не делает его app store | Все |
| Forms/validation | RHF, resolvers, Zod | Сохранить; vocabulary у entity, controller/view банковской формы отдельно | 5.2, 5.4 |
| UI/charts | Mantine; Recharts/react-is обеспечивают peer graph charts | Сохранить, не удалять peer deps из-за отсутствия direct imports | Все |
| Styles/условные классы | CSS Modules, template/ternary, data-* | Сохранить один этот подход; clsx/classnames не вводить без сложности, которой здесь нет | 3–5 |
| Icons | Inline SVG | Унифицировать повторяющиеся общие рисунки в малом собственном наборе | 4 |
| Motion | motion для routes, CSS/native для рисунков | Сохранить по разным владельцам; проверить дублирование Modal отдельно | 5.1 |
| Dates | Day.js + Mantine Dates + native Date/Intl | Сохранить роль каждого; Moscow/ISO policy остаётся domain/page | 5.3 |
| Money | String + BigInt cents, display formatters | Сохранить точность и unknown≠zero; decimal library не добавлять | Все |
| API/errors | openapi-fetch/react-query/typescript | Сохранить; не добавлять axios, второй generator или schema registry | Все |
| Tests | Vitest/RTL/MSW | Сохранить; `fireEvent` принят, user-event только transitively | 2–5 |
| Catalog | Storybook 10.6.0 | Один runtime, расширить glob и границы на packages | 2–4 |
| Quality | Biome, Knip, dependency-cruiser | Дополнить действующие scopes, не ставить Steiger/ещё один analyzer | 1–2 |
| Browser/visual CI | Принятого E2E runner/visual diff нет | Ручной browser proof пилота; Playwright — при повторяемой browser-only регрессии и владельце CI | Отложено |
| Release | release-it + native npm + Gitea | Использовать текущий подход, адаптировать workspace releases; Changesets/Lerna не нужны для трёх пакетов | 6 |
### 9. Предлагаемое уточнение conventions
Это предложения для будущих задач; действующие инструкции сейчас не меняются.
AGENTS остаётся коротким router, постоянный каталог переносится в подходящий
раздел existing UI convention/README после подтверждённого перехода.
| Владелец правила | Конкретное уточнение | Когда |
| --- | --- | --- |
| `.ai/architecture.md` | Package graph вне FSD; src public APIs; app shell; никакого package → app или same-layer обхода | 1–2 |
| `.ai/conventions/ui.md` | Каталог источников и search order; тема против wrapper; props-only общие компоненты | 2–3 |
| UI + Storybook | JSON/semantic roles, explicit CSS import, светлая схема, иконки/currentColor/reduced-motion | 2–4 |
| `.ai/conventions/data.md`, TypeScript/React | Generated vocabulary vs local const unions; errors открыты; package не владеет query state | 5.2 |
| Engineering/dependencies | Workspace direct deps, peers, exact lock, packed consumer, ограничения exports | 2, 6 |
| Storybook | Один runtime, colocated package/product stories, новые reachability scopes | 2–3 |
| Testing | Поведение → риск → минимальная boundary; package artifact contract; browser proof не равен build | 2–3, 6 |
### 10. Storybook и проверки
Один существующий frontend Storybook показывает `Design System` и `Product`.
Общие stories переезжают вместе с ui/icons; foundations читают опубликованный
JSON/адаптер. Product stories остаются рядом с entities/pages. Обновляются
только globs/aliases/check scopes, не создаётся отдельное приложение каталога.
Mantine/theme/Dates/i18n уже есть в preview; Query/Auth/API providers не нужны.
Synthetic props и callbacks сохраняются; MSW остаётся в Vitest, не в worker.
| Наблюдаемый контракт | Минимальный способ доказать |
| --- | --- |
| JSON → размеры/тени/semantic CSS, unscaled breakpoints | Перенести существующие theme unit tests; не проверять каждый literal |
| JS/types/CSS/JSON доступны вне workspace | Pack + чистый consumer с type/build/render и без source aliases |
| EntryCard image fallback, actions при focus/touch | Один небольшой RTL case на собственный fallback/действие, synthetic story и browser review |
| Деньги/version/idempotency/invalid input | Существующие targeted RTL/MSW tests формы; новые cases только если меняется соответствующая boundary |
| Query/auth/recovery | Имеющиеся MSW tests; no real backend; не тестировать это через stories |
| Keyboard/name/reduced motion/narrow layout | Выбранные собственные interactions в browser; примитивные гарантии Mantine не переписывать |
`build:catalog` не исполняет `play`; существующий `storybook.test.tsx` проверяет
лишь выбранные группы. Не обещать screenshot regression по факту сборки.
Snapshot всех компонентов и полный новый E2E stack не планируются.
Новые workspace-папки не охвачены автоматически: нынешние TS/Knip/deps/story
настройки главным образом ориентированы на `src`. Package type/build/lint,
dependencies и запрет app imports вводятся вместе с соответствующим пакетом.
Нельзя временно исключить packages целиком и назвать gate полным. Same-layer
rule сначала проверяется на текущем graph, затем включается для чистого scope;
legacy exception имеет имя, причину и задачу удаления. Existing coverage floors
не понижаются; перенос tests/coverage ownership не должен скрыть потерю сценария.
### 11. Локальная работа и потребление
Приняты приватные npm workspaces внутри frontend: один install/lockfile,
сборка пакетов и потребление через exports. Проверки охватывают приложение,
пакеты и Storybook. Собранные tarballs проверены вне workspace без source aliases;
эта проверка выявляет отсутствующие exports, типы, CSS и несовместимые peers.
Публикация, независимый SemVer, package release automation и настройка Nexus
не входят в текущий переход. Вернуться к ним можно при подтверждённой пользе
для независимого потребителя; тогда отдельно определить registry, права,
неизменность версий, совместимость и откат. Текущий app release не меняется.
## UI/UX Applicability and Package
**Applicability: N/A для нового визуального дизайна.** Цель — сохранение уже
существующего приложения, без новых экранов и рисунков. Новый макет/visuals и
hash registry не создаются. Для проверки переноса выбран следующий scope.
Пилот: `AccountPage → useAccountPageViewModel → useBrokerageEvents →
BrokerageEventsPreview → BrokerageEventCard → EntryCard`. Это read-only
preview выплат; `PositionCard` не использует EntryCard и не подходит для
переноса без изменения представления. Generic icons в пилоте отсутствуют и
мигрируют отдельно в Card/Cards.
Сохраняются loaded/loading/error/empty, missing amount как unknown, а не ноль,
registry-date/account metadata, валюта, logo fallback, длинные строки,
preview limit и ссылка полного списка. Сверяются desktop и viewport 375px,
доступный focus и отсутствие горизонтального overflow. У generic EntryCard
actions дополнительно проверяются fine/coarse pointer и reduced motion на
synthetic story. Текущий RecentEvents error-priority не меняется молча.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | affected | Workspaces tokens/icons/ui, ограниченная миграция, checks и package release integration |
| backend | N/A | Пакеты не меняют HTTP/auth/storage; замечание об errors вынесено в отложенный scope |
| specs | affected | Этот change и новые требования к поставке/иконкам; main specs обновятся только после принятой реализации |
| docs | affected | После поставки уточняются design overview и описание frontend container/release boundary |
## Solution Assessment
| Решение | Fit / зрелость / ограничения / стоимость | Выбор |
| --- | --- | --- |
| Существующие npm workspaces + Vite/TS | Стандартная упаковка; не требует нового Git repo; CSS/peers и published surface нуждаются в проверке | Да |
| Отдельный design-system repo | Ясная независимость ownership, но удваивает окружение каталога/CI при одном app | Позже по явному consumer/owner trigger |
| Style Dictionary или собственный token compiler | Полезны нескольким форматам/платформам; сейчас JSON + Mantine adapter уже решают задачу | Нет; runtime trust boundary не меняется |
| Ручные React SVG / SVGR pipeline | Несколько рисунков дешевле поддерживать в одном TSX source; SVGR нужен каноническим raw SVG exports | Ручные SVG-компоненты сейчас, без генератора |
| release-it/native npm / Changesets | Первый стек уже принят; второй полезен большому потоку независимых release contributions | Сохранить существующий app release; независимый package release отложен |
Новые внешние runtime dependencies не требуются. Риск supply chain сохраняется
под существующим security gate; peer/version checks и registry ACL нельзя
заменить предположением о готовом Nexus.
## Risks / Trade-offs
- Workspace links скрывают отсутствующие exports/assets и конфликт React:
независимый packed consumer обязателен.
- Package graph расширяет проверяемую область: старый зелёный `deps src` не
подтверждает новые packages. Scopes расширяются при появлении владельца.
- CSS order/global reset влияют на всё приложение. Сначала перенос без
изменения значений, затем отдельно semantic roles и Modal consolidation.
- Общие исходники в frontend — осознанный выбор для одного потребителя;
публикация не означает уже существующий независимый каталог/команду.
- Изменение view/controller рядом с денежной формой рискованнее UI extraction:
это отдельная поздняя задача с сохранением tests version/idempotency.
## Migration Plan
Границы owners → package foundations → общие компоненты и read-only пилот →
иконки и точечное выравнивание → packed consumer → удаление совместимости.
Единственный подробный backlog и зависимости находятся в [tasks](tasks.md).
Во время перехода `shared/theme` и три старых UI entrypoints могут быть тонкими
re-exports package API. Они не содержат второй JSON, CSS или реализации.
Новые consumers используют package paths; добавлять exports/логику в facade
запрещено. Удаление — когда поиск оставшихся runtime/test/story imports даёт
ноль и app/catalog/packed consumer прошли gates. Старые inline icons живут до
своего consumer batch, но новые копии извлечённых рисунков не добавляются.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | required: меняются локальные imports, сборка и владельцы компонентов |
| Trigger | Нет exports/CSS/types, peer conflict, несовместимое представление либо failed pilot/recovery |
| Actions | Вернуть согласованный diff приложения, packages, imports и lockfile; при выпуске приложения использовать прежний app image |
| Data safety | API/storage/money wire format не меняются; не откатывать чужие продуктовые изменения и не сбрасывать пользовательские данные |
| Verification | Повторить соответствующий consumer gate и пилот на прежней версии; проверить восстановление согласованного состояния приложения и packages |
### Осознанно отложенное
| Находка/возможность | Причина и условие возврата |
| --- | --- |
| F8: RecentEvents error приоритетнее cached data | Реальное отличие от общего recovery convention. Не менять при extraction; отдельная product recovery задача после согласования expected stale/error UX, с refetch-error regression test |
| F9: ProblemDetails errors schema/example и validation mapping | Отдельная backend contract задача: metadata + HTTP behavior test, затем предоставленный snapshot; не основание вручную исправлять frontend DTO |
| F12: Основная frontend-quality-guardrails spec требует initial 500 kB и baseline lazy chunks, а действующий verify не содержит такого gate | Отдельно согласовать актуальность сохранённого требования и способ enforcement. Не считать Vite warning блокирующим budget gate; package migration сравнивает фактический размер сборки, не заявляя выполнения отсутствующей проверки |
| Единый recovery classifier всех investment errors | Одинаковые codes ещё не доказывают одинаковый UX; объединять только подтверждённый общий recovery без нарушения FSD |
| Business/API packages, dark/mobile, raw SVG pipeline, Playwright/visual service | Нет принятого второго consumer или значимого browser-only regression scope |
## Решения для подтверждения перед зависимыми действиями
1. Публичные имена `@wealthledger/tokens`, `@wealthledger/icons`, `@wealthledger/ui`,
scope ownership и видимость. Рекомендуется приватная поставка WealthLedger;
другое решение меняет доступ и release setup, не текущие данные.
2. Nexus npm endpoint, владелец publish/read credentials, политика неизменности
и хранения прошлых версий. Эти решения нужны только при отдельном запросе внешней публикации.
3. Расхождение F12 между сохранённой spec и текущими quality-командами:
рекомендуемый default — сохранять требование до отдельного решения владельца,
не ослаблять его и не объявлять выполненным. Автоматизация либо корректировка
требования оформляются отдельным ограниченным scope после уточнения причины.
Остальное — аргументированные defaults данного предложения, а не список
неразрешённых вопросов. Перед реализацией нужен отдельный запрос пользователя;
workspaces внутри frontend уже реализованы, независимые версии остаются отложенными.
@@ -0,0 +1,69 @@
## Why
У WealthLedger уже есть общий визуальный фундамент, каталог компонентов и
типизированное взаимодействие с сервером. Следующий шаг — сделать границы
переиспользования и поставки этого фундамента явными: участник должен быстро
находить подходящее решение, а приложение — получать проверенную совместимую
версию без копирования реализаций.
Исследование текущего frontend выявило конкретные незавершённые границы:
повторяющиеся рисунки иконок, обход существующих цветовых ролей, продуктовую
навигацию в общем UI и неполную автоматизацию архитектурных ограничений.
## What Changes
**Статус: локальная реализация и итоговая проверка завершены.**
Локальные границы приложения и private workspace-пакеты реализованы;
проверенные пункты и оставшиеся ограничения перечислены в [tasks](tasks.md).
Независимое версионирование и публикация через Nexus отложены до оценки пользы
пилота и не входят в требования этого перехода.
Первоначальный комплект плановых Markdown-файлов был подготовлен по запросу
пользователя в одной сессии. Последующие разрешения касались ограниченных
этапов; они не разрешают публикацию, deployment или archive.
- Выделить используемый общий визуальный фундамент в приватные workspace-пакеты,
сохранив внешний вид и работоспособность приложения между небольшими этапами.
- Установить единый порядок выбора компонентов и понятную границу между
общим представлением, предметным UI и сценариями приложения.
- Сделать повторно используемые собственные иконки доступными с единым
визуальным и доступным контрактом.
- Подтвердить переход существующим небольшим продуктовым срезом и проверкой
потребления собранных артефактов, а не только локальных исходников.
- Уточнить применимые инженерные правила после соответствующих изменений,
сохранить существующие проверки и поэтапно закрыть найденные пробелы.
Сохраняются денежная точность, обработка неизвестных значений, поведение
авторизации, запросов, форм, восстановления после ошибок и существующих страниц.
Предметные расчёты и серверные контракты не становятся частью общего UI.
Не входят редизайн, тёмная тема, мобильный адаптер, календарь обязательных
платежей, смена работающего стека, переустройство всех репозиториев и массовое
перемещение компонентов. Публикация, deployment, commit, push, PR и archive не разрешены.
Локальная реализация ограничена отдельно согласованными этапами.
## Capabilities
### New Capabilities
Нет: новая capability на каждый пакет или каталог не требуется.
### Modified Capabilities
- `specs/frontend-design-system/spec.md`: добавить требования к потреблению
локальных пакетов общего фундамента и доступному контракту собственных
общих иконок. Существующие требования к единой теме и предметной независимости
сохраняются. [Delta](specs/frontend-design-system/spec.md).
## Impact
- Frontend: поставка общей основы, выбор источников компонентов, ограниченная
миграция потребителей и развитие существующих проверок.
- Backend: производственный контракт и поведение не меняются; отдельное
замечание к описанию ошибок остаётся отложенным исследованием владельца API.
- Specs: этот change и delta; основные as-built требования пока не изменяются.
- Docs: описание текущего локального устройства обновлено в 7.2;
независимая registry-поставка не заявляется.
Решения, альтернативы и каталог источников — в [design](design.md), единственная
очередь работ и текущее состояние — в [tasks](tasks.md).
@@ -0,0 +1,60 @@
## ADDED Requirements
### Requirement: Общий фундамент доступен через проверяемые локальные пакеты
Общий визуальный фундамент SHALL принадлежать приватным npm workspaces внутри
frontend и иметь явную публичную поверхность. Приложение SHALL использовать
пакеты через объявленные exports. Собранные артефакты MUST содержать модули,
типы, стили и данные согласно контракту каждого пакета и быть пригодными для
проверки вне рабочего дерева frontend. Общие пакеты MUST NOT требовать серверных
данных, авторизации, маршрутов или состояния WealthLedger. Совместимость пакетов
между собой и с приложением SHALL проверяться; workspace links MUST NOT быть
единственным подтверждением пригодности артефактов. Независимые релизы и
публикация в registry не входят в этот переход.
#### Scenario: Приложение потребляет собранную поставку
- **WHEN** потребитель устанавливает собранные артефакты в чистом окружении без
ссылок на рабочее дерево поставщика
- **THEN** публичные модули, типы, стили и данные доступны, общий компонент
отображается на заявленной теме без зависимостей от приложения поставщика
#### Scenario: Локальная разработка скрыла отсутствующий артефакт
- **WHEN** локальная ссылка разрешает импорт, отсутствующий в собранной поставке
- **THEN** поставка не признаётся готовой по результату одной локальной сборки;
потребление именно собранных артефактов должно быть подтверждено отдельно
#### Scenario: Приложение использует локальные пакеты
- **WHEN** frontend устанавливает зависимости из своего lockfile и собирается
- **THEN** приватные workspaces доступны через публичные exports без публикации
в registry; предметные компоненты и API-клиент остаются в приложении
### Requirement: Собственные общие иконки имеют доступный контракт
Общие иконки SHALL иметь предсказуемые размеры и возможность получать цвет
от потребителя, не определяя бизнес-смысл операции или состояние приложения.
Декоративная иконка MUST NOT создавать отдельную точку фокуса или повторять
доступное имя соседнего действия. Смысловая иконка без сопровождающего текста
MUST иметь доступное имя. Собственное движение иконки SHALL учитывать
предпочтение уменьшенного движения; выбор момента запуска по пользовательскому
сценарию SHALL оставаться у потребителя.
#### Scenario: Иконка находится в именованном действии
- **WHEN** собственная иконка дополняет кнопку с доступным именем
- **THEN** кнопка сохраняет имя и клавиатурное поведение, а рисунок декоративен
и не создаёт дополнительную точку фокуса
#### Scenario: Рисунок самостоятельно передаёт смысл
- **WHEN** иконка используется без текста и без именованного родительского
элемента как самостоятельное изображение
- **THEN** потребитель задаёт доступное имя, передающее её назначение
#### Scenario: Пользователь уменьшил движение
- **WHEN** включено предпочтение уменьшенного движения
- **THEN** необязательная анимация общей иконки не выполняется, а значение и
доступность действия сохраняются
@@ -0,0 +1,239 @@
# План перехода frontend
**На 2026-09-22 выполнены и проверены 1.1–1.2, 2.1–2.2, 3.1–3.3, 4.1–4.2, 5.1–5.4 и 7.1–7.3** по отдельному
разрешению пользователя. Остальные задачи — предложения, не разрешение
на реализацию или публикацию. Результаты и ограничения приведены ниже.
Прочитать [proposal](proposal.md), затем нужное решение [design](design.md).
Здесь единственный backlog инициативы, отдельного roadmap нет.
## Дорожная карта и зависимости
```text
1.1 AppShell owner ────────┐
1.2 import enforcement ────┴─ 2.1 tokens/workspaces ─ 2.2 ui theme
│
3.1 common states
│
3.2 EntryCard + pilot ─ 3.3 roles
2.1 ─ 4.1 icons ─ 4.2 Card/Cards consumers
2.2 ─ 5.1 Modal review
1.2 ─ 5.2 operation vocabulary ─ 5.4 form boundary
1.2 ─ 5.3 income period helper boundary
3.3 + 4.2 + packed consumer proof ─ 7.1 retire facades
Выполненные локальные 1–5 ─ 7.2 текущее as-built описание
3.2–3.3 + 5.1–5.4 + 7.1–7.2 ─ 7.3 acceptance всей инициативы
```
1.1 и 1.2 независимы по смыслу, но могут менять общие конфиги: параллельные PR
согласуют diff. 5.1–5.4 не блокируют выпуск основы и не входят скрыто в пилот.
Иконки не нужны read-only пилоту RecentEvents. Пункты внешней публикации удалены из текущего плана по решению пользователя:
пакеты остаются приватными workspaces frontend. Остальные
задачи используют предложенные defaults после отдельного запроса реализации.
### Общие условия каждой будущей сессии
- Работать в repo владельца, сначала его AGENTS и только triggered conventions;
проверить Git status, текущий HEAD и чужие изменения. План привязан к
исследованным ревизиям, не к обещанию актуального remote develop.
- Перед Node-командой активировать местную `.nvmrc`: `nvm use --silent`.
Для code/dependency PR frontend требует `npm ci` и итоговый `npm run verify`;
targeted команды ниже дополняют этот gate, не заменяют его.
- Scope PR — одна задача. Не смешивать перемещение с новым поведением,
обновлением версий библиотек и исправлением соседнего продукта.
- Значимый тест начинается с наблюдаемого результата/риска; сохранять имеющиеся
regression tests. Не добавлять тест wrapper existence, Mantine internals
или snapshots всех вариантов ради переноса.
- По завершении повторить routing, проверить diff/новые файлы, Boy Scout review,
`git diff --check`, записать реально выполненные команды и ограничения.
Непройденная проверка не отмечается как выполненная задача.
### Реальные команды и будущие проверки
Сейчас доступны: `npm run deps`, `npm run deps:storybook`, `npm run knip`,
`npm run lint`, `npm run typecheck`, `npm run openapi:check`,
`npm run test:run -- <существующий path>`, `npm run test:coverage`,
`npm run build`, `npm run build:catalog`, `npm run verify`,
`npm run release:preview`. Их поведение исследовано, но они **не запускались**
в текущей Markdown-сессии. `typecheck` может записать tsbuildinfo;
tests/builds пишут cache/coverage/dist/catalog. `openapi:generate` меняет
tracked declarations и не нужен этой миграции.
Package build/declarations/check scopes и packed consumer проверены.
Независимый package release не входит в текущий план. Не
считать вымышленный `test:packages` или `release:packages` действующей командой.
Native `npm pack --dry-run`, `npm pack`, `npm ls` использованы для проверки пакетов. `release:prepare` приложения
создаёт commit; не использовать для пробного package release.
## 1. Уточнить владельцев и существующие ограничения
- [x] 1.1 [frontend] Перенести route-aware AppShell к владельцу приложения.
- **Covers:** `N/A` — техническое исправление F3; product shell не должен стать публичной частью ui, согласно design §1/4.
- **Verification:** `npm run deps`, `npm run typecheck`, `npm run knip`, существующие route/navigation tests, итоговый `npm run verify`; browser navigation/focus и active route.
- **Читать / область:** `.ai/architecture.md`, UI, `src/app/ui/AppShell.tsx` и CSS (до переноса — shared/ui), его callers в `app/routes`, связанные auth/route tests. Найти все imports до переноса.
- **Изменить / готово:** AppShell и принадлежащие ему navigation styles находятся в `app/ui` с допустимым public import. Labels, links, Outlet, transitions, responsive shell прежние; shared/ui не знает product routes. Коротко уточнён legacy пункт architecture.
- **Не входит / сохранить:** Новая навигация, новый wrapper shell, миграция всех SVG, auth policy. Никаких изменений URL и bootstrap states.
- **Откат:** Вернуть перенос и imports одним PR; данных/внешних artifacts нет. Зависимость: нет.
- [x] 1.2 [frontend] Закрыть same-layer imports действующим dependency-cruiser.
- **Covers:** `N/A` — enforcement уже принятого FSD profile, F4; не новая пользовательская возможность.
- **Verification:** Зафиксировать вывод существующего `npm run deps` до правки; после неё тот же gate отклоняет временный контролируемый forbidden import между двумя slices, чистый graph проходит; удалить пробный import, затем `npm run verify`.
- **Читать / область:** Architecture/imports, `dependency-cruiser.cjs`, реальные alias/relative imports в pages/features/entities; public API примеры account-operations и bank-card.
- **Изменить / готово:** Точные запреты соседних slices pages/features/entities, без запрета imports внутри одного slice и без ошибочного применения к shared segments. Если baseline выявит legacy, правило включается для чистой области с адресным исключением и условием удаления, без broad ignore.
- **Не входит / сохранить:** Новый analyzer, автоматический fixer, массовая раскладка FSD, запрет page-local composition. Не писать постоянный анализатор проверки анализатора.
- **Откат:** Вернуть только rule change, обязательный semantic review остаётся. Зависимость: нет.
## 2. Подготовить пакеты без изменения представления
- [x] 2.1 [frontend] Ввести workspace tokens и охват его контрактов.
- **Covers:** `frontend-design-system` — `Общий фундамент доступен через проверяемые локальные пакеты`
- **Verification:** Будущая package-конфигурация: `npm pack --dry-run` показывает JSON; чистый временный consumer вне workspace читает JSON обычным parser; `npm ci`, расширенные существующие type/lint/Knip/deps scopes, `npm run verify`; чистые Docker builds development/production с новым workspace manifest и JSON.
- **Читать / область:** Engineering/dependencies, package/lock, TS/Knip/deps configs, `Dockerfile`, `.dockerignore`, `packages/tokens/tokens.json`, `shared/theme/theme.ts`, `theme.test.ts`; design §1–3/10. После 1.1–1.2; имена остаются предлагаемыми до принятия design.
- **Изменить / готово:** `packages/tokens` владеет тем же JSON v1; root app private; один npm workspace lock, прямые зависимости, ограниченные files/exports. Адаптер читает package JSON. Проверки явно видят packages и запрещают package → app. Docker получает workspace manifests перед npm ci и только нужные package inputs через узкую allowlist, без host node_modules/dist/.env.
- **Не входит / сохранить:** Новая schema, Style Dictionary, mobile/dark, преобразование всех токенов и CSS. Не поддерживать две редактируемые копии JSON; app appearance и все units прежние.
- **Откат:** Вернуть JSON/imports/manifest/lock как связанный diff; package ещё не опубликован.
- [x] 2.2 [frontend] Упаковать Mantine adapter и общие стили в ui.
- **Covers:** `frontend-design-system` — `Общий фундамент доступен через проверяемые локальные пакеты`
- **Verification:** Существующие theme tests на новом owner; будущие Vite library build + declaration emit и packed consumer type/build/render без app alias; на чистом checkout после `npm ci`, без прежнего package dist/cache, `npm run verify` сначала собирает packages, затем проверяет consumer. `npm ls react react-dom @mantine/core`; отдельные чистые Docker development/production builds и render темы.
- **Читать / область:** `packages/ui/src/{theme.ts,styles.css,theme.test.ts}`, facade `shared/theme/index.ts`, `app/ui/App.tsx`, `.storybook/{main.ts,preview.tsx,dependency-cruiser.cjs}`, `vite.config.ts`, `vitest.config.ts`, package scripts, `Dockerfile`, `.dockerignore`, design §2–4. Зависимость: 2.1.
- **Изменить / готово:** `ui/theme`/`ui/styles.css`, dependency tokens, peers/external React/Mantine, CSS side effects, JS/d.ts/CSS в package. App/preview явно подключают styles и прежние providers. Package build предшествует всем проверкам потребителя в local/CI/Docker; scope tests/coverage следует owner. `shared/theme` только re-export. Host dev допускает package watch; в Docker изменение packages требует документированного rebuild dev image, без нового root orchestration.
- **Не входит / сохранить:** Новый global provider, новый Storybook app, component wrappers, Modal animation cleanup. Library build не наследует app PWA/service-worker/proxy plugins. Не менять light scheme, source values, CSS order и effective theme; сравнить фактический bundle size с baseline, не заявляя отсутствующий budget gate F12.
- **Откат:** Вернуть adapter/styles/imports и manifest/lock единым PR. Временный facade удаляется в 7.1, новые consumers его не расширяют.
## 3. Общие компоненты и продуктовый пилот
- [x] 3.1 [frontend] Перенести StateView и LoadingSkeleton с их stories.
- **Covers:** `frontend-design-system` — `Общий фундамент доступен через проверяемые локальные пакеты`
- **Verification:** `npm run deps:storybook`, `npm run build:catalog`, существующие проверки StateView/LoadingSkeleton/composeStories в новом scope, будущий packed component render, `npm run verify`.
- **Читать / область:** Три UI-conventions темы, два `src/shared/ui` компонента/stories и callers, `src/test/storybook.test.tsx`, package ui. Зависимость: 2.2.
- **Изменить / готово:** Реализации и CSF stories у ui, один catalog glob включает package stories. Props без query/i18n/router; busy label/status и error alert/action сохраняются. Проверка Storybook reachability включает новый каталог. Если нужен facade, он только переэкспортирует.
- **Не входит / сохранить:** Перемещение page skeleton shapes, новый loading framework, полный набор stories для Mantine. No backend и реальные финансовые данные.
- **Откат:** Вернуть перенос; содержимое/поведение исходных components не менялось. Удаление facade — 7.1.
- [x] 3.2 [frontend] Перенести EntryCard и подтвердить пилот RecentEvents.
- **Статус 2026-09-22:** Реализация, 278 tests/full verify, Docker, packed consumer и browser desktop/375px прошли. Пользователь подтвердил успешную ручную проверку coarse pointer/reduced motion по предложенному чек-листу; browser-ограничение закрыто. CSS перенесён побайтово.
- **Covers:** `frontend-design-system` — `Общий фундамент доступен через проверяемые локальные пакеты`
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage.test.tsx`, существующие ledger/CardPage cases использующих EntryCard; минимальный UI-test собственного image fallback при необходимости; `npm run deps:storybook`, `npm run build:catalog`, packed consumer render, `npm run verify`; browser сравнение 375px/desktop.
- **Читать / область:** `EntryCard.tsx`/CSS и все callers; `AccountPage.vm.ts::toRecentEvents`, `useBrokerageEvents`, `BrokerageEventsPreview`, `BrokerageEventCard` и их stories, design UI/UX scope. Зависимость: 3.1.
- **Изменить / готово:** Один EntryCard в ui с тем же CSS/props; entity пилота импортирует публичный ui package. В Storybook — существующие RecentEvents states и малая generic story длинного контента/image fallback/actions. Пилот реально проходит через API owner до прежнего presentation, без смены data flow. Остальные callers могут пока использовать re-export.
- **Не входит / сохранить:** Generic icons, PositionCard rewrite, исправление F8. Сохраняются unknown≠zero, currency/date/account, logo fallback, preview limit/full-list link; у других callers — sign и disabled placeholder/archive guards.
- **Откат:** Вернуть consumer import и extraction PR. Не вводить вторую реализацию EntryCard для пилота. Facade removal — 7.1.
- [x] 3.3 [frontend] Подключить существующие semantic roles внутри EntryCard.
- **Статус 2026-09-22:** Реализованы border/subtle/surface. Positive/negative уже были semantic (shade 9); neutral gray-7 оставлен без неточного сопоставления. npm ci/full verify (278 tests) и browser computed colors/narrow/focus прошли. Пользователь подтвердил ручную проверку coarse pointer/reduced motion по чек-листу; пункт закрыт.
- **Covers:** `N/A` — F1: применение уже существующей темы после структурного переноса, без нового UI behavior.
- **Verification:** Сравнить computed colors и выбранные EntryCard/RecentEvents states до/после; gray-3→border, gray-0→subtle эквивалентны; текущие positive/negative (shade 9) сохранены; background tint/white→surface проверен; `npm run verify` и browser narrow/focus/coarse-pointer/reduced-motion.
- **Читать / область:** ui EntryCard/CSS, tokens color.roles, ui/theme resolver, generic и product stories. Зависимость: 3.2.
- **Изменить / готово:** Прямые эквиваленты заменены semantic CSS variables; текущий вид и actions не меняются. Если эквивалентность конкретной пары не доказана, оставить её локальной и пояснить, не создавать токен автоматически.
- **Не входит / сохранить:** Новые palettes, card brand gradients, компонентный токен на каждое свойство, массовый CSS sweep.
- **Откат:** Вернуть только CSS/inline-role diff; package ownership остаётся.
## 4. Собственные общие иконки
- [x] 4.1 [frontend] Подготовить минимальный icons package.
- **Covers:** `frontend-design-system` — `Собственные общие иконки имеют доступный контракт`; `frontend-design-system` — `Общий фундамент доступен через проверяемые локальные пакеты`
- **Verification:** Будущие package build/declarations/pack consumer checks; synthetic decorative и meaningful icon examples с именем/размером/currentColor; `npm ls react`, `npm run deps:storybook`, `npm run verify`.
- **Читать / область:** Card/Cards inline plus/pencil/trash, `BankOperationIcon` как пример accessibility, `bind-icon-animation` только для границ ответственности, design §5. Зависимость: 2.1; после 2.2 можно переиспользовать package build pattern без общего самописного генератора.
- **Изменить / готово:** Именованные Add/Edit/Delete SVG-компоненты, один TSX source на рисунок, React peer/external, public types, ограниченные exports. Static package не тянет motion/UI/domain; декоративность по умолчанию и доступное meaningful использование документированы.
- **Не входит / сохранить:** SVG registry по строке, raw SVG+TSX дубли, SVGR/dependency, перенос категорий/навигационных анимаций. Сохранить geometry/stroke/size существующих рисунков, без редизайна.
- **Откат:** Удалить ещё не используемый package diff; до публикации внешнего контракта нет. Если 4.2 уже интегрирован, откатывать согласованно.
- [x] 4.2 [frontend] Перевести только повторяющиеся Card/Cards actions на icons.
- **Covers:** `frontend-design-system` — `Собственные общие иконки имеют доступный контракт`
- **Verification:** `npm run test:run -- src/pages/card/ui/CardPage.test.tsx`, `npm run test:run -- src/pages/cards/ui/CardsPage.test.tsx`, `npm run verify`; browser accessible names/keyboard и appearance выбранных Add/Edit/Delete actions.
- **Читать / область:** `pages/card/ui/CardPage.view.tsx`, `pages/cards/ui/CardsPage.view.tsx`, ActionIcon consumers и imports. Зависимость: 4.1.
- **Изменить / готово:** Повторяющиеся SVG заменены named imports; размеры/strokeWidth задаются явно, если раньше различались. Имя и disabled остаются на action; внутри SVG нет дополнительного focus. В изменённом scope больше нет старых копий этих рисунков.
- **Не входит / сохранить:** BankOperationIcon mappings, формы/mutations, поведение edit/delete и confirm dialogs, иной визуальный стиль.
- **Откат:** Вернуть caller diff; пакет может оставаться неиспользуемым до отдельного решения, опубликованную версию не удалять.
## 5. Адресные улучшения после основы
Эти задачи самостоятельны. Их незавершённость не оправдывает расширение
пакетного PR или утверждение, что изменено поведение формы в пилоте.
- [x] 5.1 [frontend] Проверить и при необходимости свести Modal animation к одному владельцу.
- **Статус 2026-09-22:** BankCardForm browser A/B показал различный lifecycle: CSS даёт mount entrance, Mantine — exit/callback. Production-код сохранён; причины документированы в UI convention. Пользователь подтвердил ручную проверку reduced motion, Escape/Отмена и возврата фокуса; пункт закрыт.
- **Covers:** `N/A` — F5, устранение подтверждённого дублирования механизмов без нового продукта.
- **Verification:** Browser comparison открытия/закрытия существующей банковской Modal: overlay, motion, focus return, reduced motion; relevant Modal/form tests и `npm run verify` только при code diff.
- **Читать / область:** ui theme Modal defaults, ui global styles, BankCardForm/OperationForm consumers. Зависимость: 2.2.
- **Изменить / готово:** Выбран один механизм либо документирован конкретный различный смысл обоих. При эквивалентности предпочтён Mantine transition и убран только лишний CSS. Наблюдение «видимых проблем нет» само по себе не требует нового кода.
- **Не входит / сохранить:** Новые timings, анимационная система, замена motion маршрутов. Keyboard/focus/closing rules неизменны.
- **Откат:** Вернуть animation-only diff; component API/data не меняются.
- [x] 5.2 [frontend] Свести runtime vocabulary банковских operation kinds у entity.
- **Covers:** `N/A` — часть F6, одна типизированная коллекция для существующих schema/choices без изменения wire contract.
- **Verification:** `npm run typecheck`, `npm run test:run -- src/pages/card/ui/OperationForm.test.tsx`, `npm run deps`, `npm run verify`; все четыре прежних вида и defaults сохранены.
- **Читать / область:** `entities/bank-card/api/queries.ts` и index, `OperationForm.tsx`/`.view.tsx`/`.types.ts`, TypeScript/data conventions. Зависимость: 1.2.
- **Изменить / готово:** Schema и choices используют один `as const` набор, проверенный относительно generated `BankOperation['kind']`. Не добавлен enum, DTO copy или cast неизвестного code; строки UI остаются i18n.
- **Не входит / сохранить:** Generic errors registry, backend enum generation, механическое именование каждого literal, controller refactor.
- **Откат:** Вернуть маленький vocabulary/import diff; значения wire прежние.
- [x] 5.3 [frontend] Отделить pure period helpers от AccountIncomePage VM.
- **Covers:** `N/A` — F7, устранение транзитивной зависимости presentation story от VM по design §6/10.
- **Verification:** Existing account-income targeted tests, `npm run deps:storybook`, `npm run build:catalog`, `npm run verify`; synthetic form submit/reset/error states без API/query providers.
- **Читать / область:** `AccountIncomePage.vm.ts`, `components/IncomePeriodForm.view.tsx`, их imports/tests, соседний page model и Storybook convention. Зависимость: 1.2.
- **Изменить / готово:** Только реально используемые pure date helpers у page model и оба callers используют их; полезная isolated presentation story проходит reachability. Нового global date helper/service нет.
- **Не входит / сохранить:** Общий DateRangePicker, изменение диапазонов/часового пояса и перенос логики в UI package. Moscow dates, validation и submit/reset прежние.
- **Откат:** Вернуть helper move/story diff; URL/API поведение не менялось.
- [x] 5.4 [frontend] Отделить controller банковской OperationForm от presentation.
- **Covers:** `N/A` — часть F6, применение существующей container/VM/view границы без изменения денежных сценариев.
- **Verification:** До и после `npm run test:run -- src/pages/card/ui/OperationForm.test.tsx`; особенно conflict/draft/explicit version adoption, retained UUID, full refund и max money; `npm run deps:storybook`, затем `npm run verify`.
- **Читать / область:** Полные OperationForm/OperationForm.view/types/tests, entity form-validation/mutations и UI/data/testing conventions. Зависимость: 5.2.
- **Изменить / готово:** Query/mutation/recovery orchestration принадлежит page VM/controller; существующий fields view расширен только нужными presentation props/callbacks/slots. Runtime helpers сейчас приходят из banking barrel, который переэкспортирует API/hooks: эту транзитивную связь убрать через props/callbacks, type-only DTO imports допустимы. Не вводить deep-import exception и не ослаблять story gate. RHF context в fields остаётся; `CardPage` целиком не переписывается.
- **Не входит / сохранить:** Generic form engine, extraction в feature/package без второго consumer, новые requests/optimistic updates. Не менять amount string/BigInt, intent lifecycle, version adoption, source constraints, pending close guards и вложенное создание категории.
- **Откат:** Вернуть этот behavior-preserving PR. Любое необходимое изменение поведения остановить и согласовать отдельно, не прятать в refactor.
## 7. Закончить переход и описать поставленное состояние
- [x] 7.1 [frontend] Удалить только исчерпанные compatibility facades.
- **Уточнение scope:** Пользователь разрешил удалить локальные facades с проверкой packed consumer вместо registry consumer. Nexus и независимый release остаются отложенными.
- **Covers:** `N/A` — прекращение временного двойного import path после подтверждённого потребления, design Migration Plan.
- **Verification:** `rg` по runtime/test/story imports старых theme/UI paths даёт ноль; `npm run knip`, `npm run deps`, `npm run deps:storybook`, app/catalog/packed consumer checks и `npm run verify`.
- **Читать / область:** Facades `shared/theme` и EntryCard/StateView/LoadingSkeleton, все оставшиеся callers, component source catalog, frontend README и conventions. Зависимости: 3.3, 4.2 и подтверждённый packed consumer без workspace/source aliases.
- **Изменить / готово:** Оставшиеся consumers используют package API, ненужные re-exports удалены, нет дубля JSON/CSS/implementation. В existing conventions закреплены каталог и import rules, AGENTS только маршрутизирует темы.
- **Не входит / сохранить:** Массовая чистка unrelated shared/lib, запрет прямого Mantine, перенос domain UI. Старые подходы вне migrated scope не блокируются новым широким правилом.
- **Откат:** Вернуть thin facades/imports; они не восстанавливают копии реализаций. Условия удаления снова проверяются перед повтором.
- [x] 7.2 [docs] Обновить только подтверждённое as-built описание.
- **Уточнение scope 2026-09-22:** По запросу следующего пункта без browser verification описано текущее локальное состояние после 5.4. Завершение 7.1 не требуется для этого описания: переходное состояние и отсутствие registry-поставки указаны явно; после 7.1 описание facades актуализировано. После будущей поставки документация обновляется вместе с ней.
- **Covers:** `N/A` — описание фактически принятой реализации и поставки; не новый behavior contract.
- **Verification:** Docs `npm run verify` по местному CONTRIBUTING, проверка локальных ссылок/диаграмм и semantic review по поставленному frontend; `git diff --check`.
- **Читать / область:** Docs AGENTS/README/CONTRIBUTING, `design/overview.md`, `architecture/containers.md`, при необходимости release boundary в `architecture/deployment.md`; frontend принятый package contract. Основание текущего описания: выполненные 1–5; удаление facades по 7.1 отражено в документации.
- **Изменить / готово:** Тематические страницы говорят о реально используемой теме/пакетах/границах frontend и каталога. Указывается, что raw SVG/mobile/dark/API package не поставлены. При неполной 5.x не обещать завершение этих пунктов.
- **Не входит / сохранить:** Анализ/план в permanent docs, новый roadmap, переописание backend, ADR на каждый маленький выбор. Один устойчивый ADR возможен лишь если принято значимое repository/release решение и нужен его rationale.
- **Откат:** Вернуть описание вместе с отменённой поставкой; не менять исторические archives.
- [x] 7.3 [specs] Сверить реализацию с delta и закрыть плановые расхождения.
- **Статус 2026-09-22:** Semantic сверка завершена для согласованного локального объёма: proposal/design/delta согласованы с private workspaces и packed consumer; внешняя публикация исключена. Код и ручные browser checks проверены ранее. Specs verify, локальные ссылки и whitespace проверены после правок. После установки OpenSpec выполнена команда `openspec validate evolve-frontend-stack --type change --strict --store wealth-ledger-specs`: change valid. Пункт завершён.
- **Covers:** `frontend-design-system` — `Общий фундамент доступен через проверяемые локальные пакеты`; `frontend-design-system` — `Собственные общие иконки имеют доступный контракт`
- **Verification:** Semantic проверка всех delta scenarios, `npm run verify` specs, при доступном CLI `openspec validate evolve-frontend-stack --type change --strict --store wealth-ledger-specs`; локальные ссылки и `git diff --check`.
- **Читать / область:** Specs AGENTS/README, proposal/delta/design/tasks и фактические результаты consumer/rollback/accessibility checks. Зависимости полного acceptance: 3.2–3.3, 5.1–5.4 и 7.1–7.2; требования delta должны соответствовать согласованному локальному объёму.
- **Изменить / готово:** Tasks отражают только фактически проверенные результаты; согласованные изменения scope возвращены в planning artifacts, не скрыты. Main specs обновляются только в отдельно разрешённом штатном завершении после реального внедрения; untouched deferred findings остаются явно отложенными.
- **Не входит / сохранить:** Автоматический archive/approval, Evidence/state/hash registry, декларация успеха на основании одного build. Промежуточная сверка не означает завершения change.
- **Откат:** Исправить несоответствующее предложение/отметку; main specs не описывают ещё не реализованный переход.
## Переходный период и отложенные вопросы
Точный перечень отложенного — в design: F8 recovery RecentEvents, F9 backend
errors contract, F12 расхождение bundle-budget spec/gate, общий investment recovery classifier и новые потребители.
Они не входят скрыто в tasks выше. Если требуется менять HTTP/observable
recovery, нужен отдельный согласованный scope и соответствующий spec contract.
Facades допустимы только от 2.2/3.x до 7.1; новых возможностей они не получают.
Новые components/imports следуют целевому каталогу. Старые inline рисунки
допустимы лишь до миграции своего caller batch, а новые дубли уже вынесенных
рисунков не создаются. Совместимость снимается по нулю imports и успешному
consumer proof, а не по истечении произвольного срока.
Проверены задачи **1.1–1.2, 2.1–2.2, 3.1–3.3, 4.1–4.2, 5.1–5.4 и 7.1–7.3**.
Недостающие browser checks 3.2/3.3/5.1 выполнены вручную пользователем:
он подтвердил корректность всех пунктов переданного чек-листа.
Независимые версии и Nexus отложены до оценки пользы после пилота;
checkbox не разрешает дальнейшую реализацию или публикацию.
Последний полный frontend `npm run verify`: 49 test files / 282 tests,
проверки зависимостей, типов, lint, Knip, OpenAPI и сборки app/catalog прошли.
Свежий UI tarball проверен вне workspace: install, typecheck, build и browser render.
Docs `npm run verify`: 45 Markdown-файлов, 0 issues.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-10-07
@@ -0,0 +1,96 @@
## Context
Backend — один NestJS process и SQLite. Сейчас screen-driven синхронизация раскладывает запросы экранов на source work; владельцы membership, portfolio, operations, forecast, catalog и candles используют reservations, attempt/revision fences и короткие транзакции без RPC внутри них. Текущий portfolio snapshot перезаписывается и каскадно удаляется при замене подключения или исчезновении счёта. `TbankSyncService` принимает экранный POST; frontend `screen-sync` запускает его и наблюдает status. Справочные данные, результаты поиска и отрицательные lookup живут в сохранённых таблицах, а detail является зависимостью событий и рыночных данных. Новый режим требует смены инициатора, не второй конкурирующей системы записи.
## Goals / Non-Goals
**Goals:** сохранить единственного владельца публикации каждого источника; сделать расписание устойчивым к рестарту в рамках одного процесса; связать точки истории с успешной публикацией портфеля и подтверждённым доступом; отделить сетевое чтение интерфейса от брокерского обновления; перевести подробные сведения инструментов на онлайн-источник без потери сохранённых рыночных и личных данных.
**Non-Goals:** график и публичная история точек, новый механизм синхронизации между процессами, повторное сканирование полной рыночной истории и уже полных операций, изменение банковских карт и auth-refresh.
## Decisions
### Один durable clock поверх существующих source owners
В `tbank-sync` вводится одиночный backend scheduler с persisted последней попыткой для текущего подключения: начало, завершение/состояние и диагностические разделы без токенов. При успешном PUT подключения фоновой работе передаётся новая граница; при старте process scheduler сверяет её и последнее начало, не опираясь на frontend GET. Проверка часовым таймером только предлагает работу; атомарное резервирование сверяет user/revision, время начала предыдущей попытки и отсутствие ещё активной попытки. Во время работы новое резервирование не допускается; пересечённый срок пропускается, а не исполняется «вдогонку». После рестарта незавершённая попытка старого process становится прерванной, но её время начала остаётся основанием следующего запуска; новый process не публикует её поздние результаты. Успешное подключение начинает новую первую попытку, даже если прежний токен был заменён незадолго до срока. Штатный процесс инициализирует таймер и освобождает его при shutdown.
Scheduler делает один обход подтверждённого membership, затем даёт portfolio owner обновить каждый доступный счёт, operations owner — только изменяемый хвост, forecast/source owners — события бумаг состава. Передаёт доступ и ревизию владельцам, не пишет их личные наборы сам и не держит транзакцию при сетевых вызовах. Результаты отдельных источников дают общий итог `succeeded/partial/failed` и безопасные идентификаторы счёта/раздела. При недействительном токене применяется существующая блокировка личных ответов. Альтернатива — reuse screen intent с поддельным экраном `accounts`: он не покрывает операции и события для каждого счёта и случайно включает ненужные зависимости; выделение фонового orchestration над теми же owners точнее.
### История отдельно от revision, но внутри атомарной публикации
Состояние истории принадлежит `(userId, accountId)` и переживает смену token/revision; текущий snapshot и source work по-прежнему принадлежат `(userId, revision, accountId)`. Точки используют точную десятичную строку, ISO-валюту и фактическое время успешной публикации, без состава позиций. Проверка полноты оценки всех позиций предшествует вставке любых валютных точек; пустой безвалютный портфель не даёт строк. Для однозначности повторной публикации точка привязана к идентификатору успешной попытки portfolio, а её запись и замена snapshot происходят в одной короткой транзакции после conditional attempt claim. При потере revision/доступа вся транзакция отменяется. Неоценённый состав сохраняется текущим без исторических точек.
Видимость истории определяется текущим подтверждённым membership и признаком удаления счёта, а не одной лишь идентичностью исторических строк. Успешное подтверждение списка, успешный PUT нового token и DELETE обновляют пометки доступа вместе со сменой текущей границы; неуспешный PUT/GET membership не меняет их. Чтение возможной истории в будущем обязано проверять session, текущее неотклонённое подключение и активное подтверждение конкретного accountId: `deletedAt = null` само по себе не является полномочием. Это позволяет сохранить прошлые точки, не воскресить приватный снимок прежней revision и не раскрыть историю между отключением и повторным подтверждением. Поскольку endpoint истории сейчас не предусмотрен, API для её чтения не проектируется.
### Сначала разделить источники по смыслу, затем удалить screen import
Обычные GET инвестиционных областей становятся локальными для membership/portfolio и изменяемых операций/прогнозов; их freshness отражает час, а ошибки попыток живут отдельно. Исключения — первичная загрузка отсутствующего исторического интервала операций и впервые запрошенного диапазона свечей; покрытие остаётся владельцем операций/candles. Текущие quotes/незавершённые свечи обновляются по запросу не чаще часа. Удаление ручного screen POST и его наблюдения следует после перехода всех consumers на чистые reads и статус последнего обхода. Иначе нынешние `sync=observe` GET и retry-кнопка могут обходить новый график; транспортный retry сохраняется, но не вызывает брокерский импорт. Публичный DTO статуса описывает только последнюю попытку и читается под той же auth/access boundary. Backend публикует OpenAPI как контракт, frontend обновляет свой snapshot из него отдельно.
### Онлайн-справочник без разрушения зависимостей
Публичный instrument detail, company и catalog search идут через существующие T-Bank/MOEX/NCR clients по запросу с проверкой UID/ticker и сохранением различия «не найдено / неоднозначно / источник недоступен». Общая активная работа может коалесцировать одинаковые сетевые вызовы в процессе, но отдельная постоянная копия справочника и отрицательного lookup не создаётся. Для прогноза необходимые атрибуты запрашиваются внутри его работы; ошибка не заменяет последний успешный forecast. Для сохранённых quotes, candle coverage, событий и portfolio labels остаётся только минимальная стабильная identity, нужная для ключей и чтения; ссылки и пользовательские данные не зависят от удаляемого detail payload. Старые справочные записи выводятся из использования до рассмотрения их удаления; каскады не должны уничтожать свечи. При сбое онлайн-источника блок detail показывает отдельную ошибку, остальные сохранённые данные доступны в своих границах.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — на инвестиционных экранах убираются ручные команды синхронизации, добавляются 30-минутное перечитывание и статус последнего обхода на кабинете. Макет графика не нужен. Пакет полностью inline; локальные PNG/SVG не требуются.
**Package revision:** `1` (этот Design).
**Screens, states and interactions:**
| Экран / блок | Без результата | Данные сохранены | Сбой / частичный успех | Действие |
| --- | --- | --- | --- | --- |
| Кабинет/счета | штатная первая загрузка и отсутствие доступных счетов | прежние карточки и время обновления | сообщение о последнем обходе: состояние, время, названия проблемных счетов/разделов без token/error payload | переход к счёту или настройкам; кнопки синхронизации нет |
| Портфель/операции/доход/события | локальная загрузка либо недостающий исторический период | сохранённые значения и исходное время остаются при перечитывании | server-side stale отдельно от transport error; прежние значения не становятся новым snapshot | повтор только HTTP-чтения при сетевой ошибке |
| Карточка инструмента и поиск | справочный блок ожидает онлайн-ответ; свечи могут впервые загружаться отдельно | сохранённые позиции, операции, подписи, котировки/свечи и события сохраняют самостоятельные состояния | ошибка справочного блока не скрывает независимые данные; подпись snapshot не обещает свежие подробности | открытие и повтор HTTP-чтения сведений без команды синхронизации личных счетов |
30-минутное перечитывание действует только пока инвестиционный экран открыт; для онлайн-справочного блока оно также означает новый запрос сведений. Время последней попытки не подменяет `lastSuccessfulAt` отдельного счёта. Текст частичного успеха включает доступный список проблемных разделов; не показываются сырые upstream сообщения.
**Responsive behavior:** использовать существующие адаптивные контейнеры и ширины списков/карточек; строка статуса переносит названия счетов и разделов на узком экране, не закрывает содержимое и имеет доступное текстовое обозначение помимо цвета. Новых плотных таблиц, графиков или мобильной навигации нет.
**Reused source and differences:** существующие as-built страницы кабинета, портфеля, операций и карточек инструмента сохраняют компоновку; новый пакет не зависит от изменяемой внешней ссылки либо архивного макета. Отличия перечислены выше: статус обхода вместо ручного refresh и локальная ошибка online detail. Идентификатор immutable archived reuse не применяется: новый статус и новые переходы состояний описаны здесь inline, не объявляются точной копией прошлой версии.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Убрать screen-triggered mutation и инвестиционные sync-кнопки, сохранить transport retry, добавить периодическое чтение и статус на кабинете, независимо показывать ошибку online detail. |
| backend | `affected` | Durable scheduler, учёт частичного результата и доступа, точки истории, локальные read boundaries, онлайн-справочник и опубликованный API статуса. |
| specs | `affected` | Нормативные delta requirements для нового расписания, истории и обновлённых инвестиционных/клиентских сценариев. |
| docs | `affected` | Обновить объяснение свежести, областей и глоссарий, зафиксировать новое решение относительно ADR 017 без переписывания его исторического контекста. |
## Solution Assessment
| Вариант | Fit и maturity | Security / data integrity | Operating cost и ограничения | Migration cost |
| --- | --- | --- | --- | --- |
| Внешний cron/worker с отдельным экземпляром | Зрелый для fleet, но дублирует приложение при single-process SQLite | Нужны распределённые lock/lease и граница токенов между процессами | Отдельный deployment и координация | Высокая; против текущей single-process модели |
| Nest scheduling dependency | Зрелые cron decorators/timers, но сама библиотека не хранит результат попыток | В любом случае нужны persisted CAS/fences для restart и подключения | Ещё один runtime package и жизненный цикл без выигрыша от cron expression | Небольшая, но ненужная зависимость |
| Native Node timer + существующие SQLite/Prisma reservations | Подходит для одного процесса; точность минутных тиков не подменяет persisted время начала | Conditional reservation + revision/attempt fence, история в DB transaction | Нет нового сервиса или пакета; при нескольких процессах требуется отдельная координация | Умеренная, повторное использование владельцев источников |
Выбран native timer с durable CAS: уже существующий `TbankSyncWork` и SQLite дают необходимые primitives, но сам таймер не является гарантом уникальности. Для истории использовать уже установленный Prisma/SQLite вместо time-series базы; данные малы, rollback и backup живут в существующем deployment. Для справочных сведений использовать действующих integration clients и online reads; новая cache/service boundary не оправдана, а долговременное кэширование прямо противоречит принятым требованиям. Отказ от него повышает зависимость detail от T-Bank, поэтому ошибки изолируются от сохранённых финансовых наборов. Перед настройкой фактического числа upstream вызовов требуется сверить действующие первичные ограничения провайдера и измерить размер/прирост локальной базы на реальном объёме; произвольные внешние числовые лимиты не фиксируются как допущение.
## Risks / Trade-offs
- При простое backend точки пропускаются; нет искусственной интерполяции и накопленной очереди RPC.
- Один timer без отдельной межпроцессной аренды небезопасен для нескольких экземпляров: deployment сохраняет единственный process.
- Переход на онлайн-справочник увеличивает latency и чувствительность detail/search к upstream; нельзя распространять ошибку на сохранённый портфель или его события.
- Исчезновение/возврат счёта и одновременная смена token требуют fence внутри коротких транзакций; `deletedAt` без актуального подтверждения membership не даёт права чтения.
- Удаление таблиц справочника до развязки FK/dependencies может стереть историю свечей; destructive cleanup откладывается до проверки сохранности рыночных данных.
- Старый frontend и новый backend должны сосуществовать в переходе: публикация DTO статуса предшествует удалению screen POST; потребителю передаётся явный OpenAPI artifact.
## Migration Plan
1. Добавить историю и metadata последней попытки совместимым расширением SQLite; не переписывать уже применённые migrations и не выполнять reset на рабочей базе. Привязать запись истории к существующему portfolio publisher и видимость к подтверждённому membership/connection lifecycle.
2. Ввести backend-only расписание поверх существующих source owners и read-only статус; включить первичную загрузку и восстановление после рестарта при сохранённом времени попытки. Только после проверки idempotency и отказов перевести GET на read-only для текущих личных данных.
3. Развязать online reference от долговременных detail/search/lookup записей, сохранив минимальные identity и таблицы quotes/candles/forecast; сначала перевести readers, затем совместимой миграцией удалять лишь действительно неиспользуемые справочные payload, не каскадируя рыночную историю.
4. Получить опубликованный backend OpenAPI и отдельно обновить frontend query/статус/кнопки в рамках согласованного contract. Затем убрать неиспользуемый screen-triggered endpoint; зафиксировать изменение ADR и as-built страниц. Измерить рост SQLite и provider call volume до изменения частоты/лимитера.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Trigger | Нарушение доступа к истории после замены token, повторная публикация точек, порча сохранённых свечей при переходе справочника либо несовместимость нового frontend с API статуса. |
| Actions | Остановить scheduler, вернуть предыдущие совместимые сборки из проверенного комплекта и восстановить DB из согласованного backup перед destructive переходом; при additive стадии допустимо вернуть старый runtime с сохранением новых неиспользуемых таблиц. Не возобновлять прежний screen-triggered режим одновременно с включённым hourly scheduler. |
| Data safety | Сохранить исходные snapshots, coverage, forecast и точки истории; никогда не удалять или заново создавать production SQLite ради rollback. Перед очисткой справочника обеспечить проверенный backup/restore и доказать отсутствие каскадов в candles. |
| Verification | Проверить на копии с представительными данными доступность прежних личных данных только у текущего подключения, идемпотентность точек, сохранность candles/coverage и совместимость опубликованного API до возврата трафика. |
@@ -0,0 +1,32 @@
## Why
Сейчас актуализация инвестиционных данных зависит от открытия экранов и ручного действия. Неактивный кабинет не получает новые данные, а долгосрочную динамику стоимости счёта восстановить по последнему снимку невозможно. Нужны предсказуемое часовое обновление независимо от frontend и накопление пригодных для будущего графика оценок. Согласованный объём — [issue #22](https://git.personal.ksv741.ru/WealthLedger/main/issues/22).
## What Changes
- Обновлять доступные счета, состав и стоимость портфелей, актуальные операции и будущие события по бумагам портфеля раз в час силами backend; первое обновление выполнять после подключения. Сохранённые данные отдавать сразу, сохраняя последнее успешное состояние при ошибке и показывая результат последней попытки, включая частичный успех.
- Накапливать оценку стоимости каждого счёта по валютам на фактическое время успешного получения полной оценки, без конвертации и подмены отсутствующей цены нулём. Сохранять историю при смене токена и отключении, скрывать её без подтверждённого доступа и продолжать при восстановлении доступа к тому же счёту.
- Убрать ручную синхронизацию с T-Bank и обновление личных данных при открытии инвестиционных экранов. Открытые экраны перечитывают сохранённые данные каждые 30 минут. Сохранить первичную загрузку отсутствующих исторических интервалов и диапазонов свечей, но не перечитывать полностью загруженную старую историю автоматически.
- Получать справочные данные инструмента и результаты поиска онлайн без отдельного долговременного хранения; сохранить подписи последнего снимка портфеля, события, котировки и свечи согласно их разным правилам актуализации.
- Вне объёма: сам график и публичное чтение его точек, валютная конвертация, многопроцессное расписание, автоматическая повторная сверка уже полных старых интервалов операций и свечей, а также изменение действий для ручных банковских карт и повторного чтения после сетевой ошибки.
## Capabilities
### New Capabilities
- `specs/hourly-investment-sync/spec.md`: независимый от открытых экранов часовой обход и наблюдаемый результат последней попытки.
- `specs/portfolio-value-history/spec.md`: точная история оценки по счёту и валюте с учётом полноты и границы доступа.
- `specs/online-instrument-information/spec.md`: получение справочных сведений и поиска онлайн без сохранённой самостоятельной копии, отдельно от котировок, свечей и событий.
### Modified Capabilities
- `specs/portfolio-view/spec.md`: чтение последнего снимка без запуска синхронизации, часовая свежесть, отсутствие ручной синхронизации и сохранение доступных подписей позиций.
- `specs/brokerage-accounts/spec.md`: быстрое чтение сохранённых счетов и сводок, видимость частичных ошибок и периодическое перечитывание экрана.
- `specs/tbank-connection/spec.md`: первая загрузка после подключения и сохранение скрытой истории стоимости при отключении/замене токена.
- `specs/brokerage-account-upcoming-events/spec.md`: обновление прогноза по расписанию с сохранением последнего полного результата при сбое.
- `specs/frontend-data-resilience/spec.md`: разделение повтора HTTP-чтения и запрещённой ручной синхронизации при часовом обновлении.
- `specs/brokerage-account-ledger/spec.md`: первичная загрузка отсутствующего периода без повторной сверки уже полного исторического периода при открытии страницы.
## Impact
Пользователь быстрее открывает инвестиционные экраны и видит время и возможную неполноту последней синхронизации; открытый экран получает новые сохранённые данные без команды обновления. Затронуты инвестиционные экраны, фоновое обновление и хранение личных данных, правила доступа после смены подключения, отображение ошибок и онлайн-получение информации об инструментах. Переход требует согласовать действующие требования, которые пока предписывают короткие сроки свежести и ручные повторы.
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Отсутствующий исторический интервал загружается только впервые
Система MUST позволять авторизованному User при первом открытии ещё не загруженного периода операций доступного Brokerage Account начать получение недостающего интервала без ожидания T-Bank в локальном ответе. Полностью загруженный прошлый интервал MUST NOT повторно сверяться из-за чтения страницы, часового обхода или удалённой команды ручной синхронизации. Изменяемый хвост операций MUST обновляться часовым обходом. Незавершённый интервал и ошибка загрузки MUST NOT обозначаться полными, а последний подтверждённый результат MUST оставаться доступным.
#### Scenario: Первый просмотр отсутствующего прошлого периода
- **WHEN** User открывает доступный исторический период без подтверждённой полноты
- **THEN** система возвращает локальный результат с явным состоянием неполноты и начинает загрузку недостающих данных
#### Scenario: Повторное открытие полного периода
- **WHEN** User повторно открывает уже полностью загруженный прошлый период
- **THEN** система читает сохранённые операции без повторной сверки с T-Bank
#### Scenario: Ошибка страницы загрузки
- **WHEN** загрузка части отсутствующего периода завершилась ошибкой
- **THEN** ранее подтверждённые операции сохраняются, а неполная часть не помечается как полная
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Upcoming events use complete source results
Система MUST возвращать только последний полностью успешно сформированный прогноз по доступному счёту. Если необходимые данные брокера или MOEX недоступны во время часового обновления, система MUST NOT заменять последний успешный прогноз частичным списком или изменять время его успеха. Инструменты, для которых источник не предоставляет поддерживаемые события, MUST пропускаться без синтетических строк «нет данных». Получение сохранённого прогноза MUST NOT запускать синхронизацию при открытии экрана; ошибку последней попытки MUST показывать отдельно.
#### Scenario: Недоступность источника
- **WHEN** необходимый источник данных брокера или MOEX временно недоступен при часовом обновлении
- **THEN** последний полный прогноз остаётся доступен с исходным временем, частичный список не публикуется, а ошибка отражается в статусе обхода
#### Scenario: Инструмент без поддерживаемых данных
- **WHEN** Portfolio содержит инструмент, для которого источник не предоставляет поддерживаемые будущие события
- **THEN** система не добавляет для него синтетическую строку в результат
#### Scenario: MOEX отвечает без запрошенного блока
- **WHEN** MOEX отвечает успешно, но не содержит запрошенного блока данных (например, дивидендов)
- **THEN** система трактует ответ как отсутствие данных по инструменту и не завершает обновление ошибкой
#### Scenario: Чтение при временном сбое обновления
- **WHEN** User открывает события доступного счёта после неудачного часового обновления и для счёта существует прежний полный прогноз
- **THEN** система возвращает прежний прогноз без ожидания источника и обозначает ошибку обновления отдельно
@@ -0,0 +1,84 @@
## MODIFIED Requirements
### Requirement: Состояния недоступности списка счетов
Система MUST читать подтверждённый доступный список счетов текущего подключения из последнего сохранённого результата и MUST NOT ожидать T-Bank или запускать проверку только из-за GET. При отсутствии T-Bank connection система MUST отвечать `409` Problem Details с code `TBANK_NOT_CONNECTED`. Если T-Bank уже отклонил сохранённый token как неаутентифицированный или без прав, система MUST отвечать `400` Problem Details с code `INVALID_TBANK_TOKEN` и сохранять connection, не выдавая личных данных. Временная ошибка часового обновления MUST оставлять последний подтверждённый список доступным с исходным временем и отдельным состоянием ошибки; при ещё неизвестном списке система MUST обозначать ожидание первой загрузки, не выдавая частичный список за полный.
#### Scenario: Подключение отсутствует
- **WHEN** User без сохранённого T-Bank connection запрашивает список
- **THEN** система не обращается к T-Bank и отвечает `409` Problem Details с code `TBANK_NOT_CONNECTED`
#### Scenario: Сохранённый token больше не действителен
- **WHEN** T-Bank отклоняет сохранённый token при часовом обновлении списка как неаутентифицированный или без прав
- **THEN** последующее чтение получает `400` Problem Details с code `INVALID_TBANK_TOKEN` и не раскрывает личные данные, но connection сохраняется
#### Scenario: T-Bank временно недоступен
- **WHEN** часовая проверка списка завершается временной либо иной upstream-ошибкой после ранее успешной проверки
- **THEN** обычное чтение возвращает последний подтверждённый список без ожидания T-Bank, сохраняя его исходное время и отдельную ошибку обновления
#### Scenario: Первый обход ещё не завершён
- **WHEN** User открывает список после подключения до получения первого подтверждённого списка
- **THEN** система не выдаёт частичный или неподтверждённый список за полный и сообщает о продолжающейся загрузке
### Requirement: Экран списка брокерских счетов
Клиент SHALL загружать список при открытии защищённого маршрута `/accounts` без ручной синхронизации и без автоматического refetch при возврате фокуса. Клиент MUST использовать успешный frontend-кэш списка в течение 60 секунд. После истечения этого срока клиент MAY перечитать локальный результат; пока экран открыт, он MUST перечитывать результат backend каждые 30 минут без запуска импорта. При уже имеющемся успешном списке он MUST сохранять последний успешный Client Cached Snapshot видимым во время фонового перечитывания и после его ошибки, ясно отделяя это состояние от успешного нового ответа. При успешном непустом ответе клиент MUST отобразить адаптивный одноколоночный список широких интерактивных карточек счетов с локализованными типом и, если доступна, датой открытия без времени; каждая карточка MUST ясно показывать тип, имя, финансовую сводку, вторичные метаданные и заметное действие открытия счёта, вести на `/accounts/:accountId` по всей области и сохранять доступное клавиатурное состояние. После успешной замены или удаления T-Bank connection клиент MUST инвалидировать список счетов.
Для доступной `portfolioSummary` карточка MUST показывать не более двух Money-итогов в исходных валютах и обозначать наличие остальных валют, если они есть; она MUST NOT показывать или подразумевать FX-конвертацию, межвалютный агрегированный итог либо Cash Balance как отдельный второй итог. При `portfolioSummary: null` карточка MUST показывать локальное безопасное состояние временной недоступности сводки, сохраняя метаданные счёта и возможность открыть его. При доступной пустой сводке карточка MUST не выдавать отсутствие итогов за временную ошибку.
#### Scenario: Пользователь открывает счёт из списка
- **WHEN** клиент успешно загрузил непустой список и User выбирает карточку счёта
- **THEN** клиент переходит на `/accounts/:accountId` с `id` выбранного счёта
#### Scenario: Список пуст
- **WHEN** клиент получает успешный пустой массив
- **THEN** клиент показывает штатное сообщение об отсутствии инвестиционных счетов T-Bank
#### Scenario: Список берётся из свежего frontend-кэша
- **WHEN** User повторно открывает список в течение 60 секунд после успешной загрузки
- **THEN** клиент показывает сохранённые счета без нового API-запроса
#### Scenario: Устаревший список загружается повторно
- **WHEN** User открывает список после истечения 60 секунд frontend-кэша и уже существует последний успешный список
- **THEN** клиент оставляет этот список видимым во время нового чтения сохранённого результата и ясно обозначает фоновое перечитывание без команды импорта
#### Scenario: Ошибка фонового обновления не скрывает snapshot
- **WHEN** перечитывание устаревшего списка завершается ошибкой при наличии последнего успешного Client Cached Snapshot
- **THEN** клиент сохраняет snapshot видимым, безопасно сообщает о неуспешном чтении и предоставляет повтор именно чтения
#### Scenario: Карточка ограничивает валютные итоги
- **WHEN** доступная сводка счёта содержит итоги в трёх или более валютах
- **THEN** карточка показывает не более двух исходных валютных итогов и обозначает наличие остальных без общего сконвертированного значения
#### Scenario: Недоступность сводки локальна карточке
- **WHEN** клиент получает список, в котором `portfolioSummary` одного счёта равна `null`
- **THEN** он показывает временную недоступность только на этой карточке, не скрывает остальные карточки и сохраняет переход на детали недоступной сводки счёта
#### Scenario: Пустая сводка не является ошибкой
- **WHEN** клиент получает доступную `portfolioSummary` с пустым `totalsByCurrency`
- **THEN** он показывает нейтральное состояние отсутствия финансовых итогов, а не состояние временной недоступности
### Requirement: Клиентские состояния ошибки списка счетов
Клиент MUST NOT автоматически повторять неуспешный запрос списка вне согласованного периодического перечитывания открытого экрана. При `TBANK_NOT_CONNECTED` клиент SHALL показать объяснение отсутствия подключения и действие «Открыть настройки T-Bank». При `INVALID_TBANK_TOKEN` клиент SHALL показать объяснение недействительного token и то же действие. При `TBANK_UNAVAILABLE` клиент SHALL показать безопасное объяснение временной недоступности, действие повторного HTTP-чтения и действие открытия настроек T-Bank. При иной ошибке клиент MUST показать общий безопасный error-state только с действием повторного чтения. Если последний успешный Client Cached Snapshot отсутствует, эти состояния MUST NOT показывать список; если snapshot существует во время фонового перечитывания, клиент MUST сохранить его видимым и показать соответствующее безопасное предупреждение и только применимые действия. Эти действия MUST NOT инициировать синхронизацию с T-Bank.
#### Scenario: Подключение требуется
- **WHEN** клиент получает `TBANK_NOT_CONNECTED`
- **THEN** клиент не показывает список и предлагает открыть настройки T-Bank
#### Scenario: Известная ошибка не повторяется автоматически
- **WHEN** запрос списка завершается известной ошибкой
- **THEN** клиент не выполняет немедленный повтор, пока User не выберет действие повторного чтения либо не наступит очередной 30-минутный срок открытого экрана
#### Scenario: Token требует внимания
- **WHEN** клиент получает `INVALID_TBANK_TOKEN`
- **THEN** клиент не показывает список и предлагает открыть настройки T-Bank
#### Scenario: Временная ошибка T-Bank
- **WHEN** клиент получает `TBANK_UNAVAILABLE`
- **THEN** клиент не показывает список и предлагает повторить локальное чтение либо открыть настройки T-Bank без команды импорта
#### Scenario: Неизвестная ошибка загрузки
- **WHEN** клиент получает ошибку без известного кода списка счетов
- **THEN** клиент не показывает список и предлагает только повторить чтение
#### Scenario: Известная ошибка при сохранённом snapshot
- **WHEN** фоновое перечитывание списка с последним успешным Client Cached Snapshot получает `TBANK_UNAVAILABLE`
- **THEN** клиент сохраняет snapshot видимым, сообщает о временной недоступности и предлагает повторить чтение либо открыть настройки T-Bank
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Client Cached Snapshot остаётся доступным при обновлении Accounts и Portfolio
Frontend MUST сохранять Client Cached Snapshot списка Brokerage Accounts или Portfolio на экране, если для этого же набора данных уже был успешно получен ответ. Начальная загрузка без такого ответа может показывать блокирующее состояние загрузки. Client Cached Snapshot MUST оставаться видимым во время перечитывания сохранённых данных и после его ошибки; он не является server-side fallback и MUST NOT изменять или интерпретировать поле `Portfolio.isStale`. Повтор после транспортной ошибки MUST повторять чтение, а не инициировать синхронизацию с T-Bank.
#### Scenario: Список Brokerage Accounts обновляется в фоне
- **WHEN** экран Accounts уже показывает Client Cached Snapshot и начинается фоновое перечитывание того же списка
- **THEN** frontend оставляет список видимым и показывает только неблокирующий индикатор обновления
#### Scenario: Обновление Portfolio завершается ошибкой после успешного ответа
- **WHEN** экран Brokerage Account уже показывает Client Cached Snapshot Portfolio, а последующее чтение этого же Portfolio завершается ошибкой
- **THEN** frontend сохраняет Portfolio видимым, показывает локализованное неблокирующее предупреждение о показе последних полученных данных и предоставляет повторную попытку чтения без команды синхронизации
#### Scenario: Server-side fallback и client cached snapshot различаются
- **WHEN** backend возвращает Portfolio с `isStale` либо перечитывание Client Cached Snapshot завершается ошибкой
- **THEN** frontend отображает соответствующее server-side либо client-side предупреждение независимо, не представляя один вид состояния другим
@@ -0,0 +1,50 @@
## Purpose
Задаёт независимое от открытых экранов часовое обновление инвестиционных данных пользователя и наблюдаемый результат последней попытки при частичных сбоях.
## ADDED Requirements
### Requirement: Часовой обход личных инвестиционных данных
Backend SHALL начинать первый обход после успешного подключения T-Bank и последующие обходы не ранее чем через 60 минут от начала предыдущей попытки для подключения. При старте после простоя он MUST учитывать время последней попытки, выполнять не более одного актуального обхода и MUST NOT воспроизводить пропущенные часы. Если попытки ещё не было и данных нет либо они старше часа, обход MUST начинаться при старте. Неуспешная попытка MUST NOT повторяться ранее следующего часового срока даже после перезапуска. Перекрывающиеся обходы MUST NOT выполняться; если срок настал во время ещё идущего обхода, соответствующий запуск пропускается.
Обход MUST проверять доступные счета и обновлять портфель каждого доступного счёта, изменяемый хвост операций и будущие события по бумагам текущего портфеля. Он MUST NOT повторно загружать уже полные прошлые интервалы операций, завершённые свечи или полную рыночную историю. Он MUST NOT зависеть от открытого frontend. Смена подключения MUST NOT позволять завершившейся поздно попытке старого подключения изменять личные данные или время их успеха.
#### Scenario: Первое подключение между часовыми запусками
- **WHEN** пользователь успешно подключает T-Bank после запуска backend
- **THEN** первый обход начинается без ожидания следующего календарного часа, а следующий допускается через час от начала первого
#### Scenario: Рестарт после неуспешной попытки
- **WHEN** backend перезапускается до истечения часа после неуспешного обхода с отсутствующими данными
- **THEN** обход не повторяется при рестарте, а последнее успешное состояние остаётся доступным согласно действующей границе доступа
#### Scenario: Перекрытый часовой срок
- **WHEN** начатый час назад обход всё ещё выполняется
- **THEN** второй обход не запускается, а пропущенная попытка не ставится в очередь сразу после завершения первой
#### Scenario: Возобновление после простоя
- **WHEN** backend не работал несколько часов и после старта обнаружил, что последняя попытка старше часа
- **THEN** он запускает только один текущий обход без синтетических прошлых обновлений
### Requirement: Сохранность и статус последней попытки
Успех одного счёта или источника MUST сохраняться независимо от ошибки другого; неполный либо неуспешный результат источника MUST NOT заменять его последнее успешное состояние или время. Backend SHALL предоставлять аутентифицированному пользователю статус только его последнего обхода: время начала, состояние (выполняется, полностью успешен, частично успешен или неуспешен) и проблемные счета и разделы без чувствительных сведений. Кабинет MUST показывать результат, время и проблемные счета/разделы при частичном успехе; отсутствие состоявшейся попытки MUST быть отличимо от успеха. Статус не предоставляет возможности начать синхронизацию с frontend.
#### Scenario: Ошибка одного счёта
- **WHEN** портфели двух счетов обновились, а третий не обновился
- **THEN** новые данные первых двух сохраняются, у третьего остаётся последнее успешное состояние, а статус обхода обозначает частичный успех и проблемный счёт
#### Scenario: Ошибка событий при успешных портфелях
- **WHEN** все портфели обновлены, но событие одной бумаги не обновилось
- **THEN** последний успешный прогноз сохраняется и статус обхода обозначает частичный успех с проблемным разделом
#### Scenario: Пользователь другого подключения запрашивает статус
- **WHEN** авторизованный пользователь запрашивает статус чужого обхода либо попытки старого подключения
- **THEN** система не раскрывает детали чужой или прежней границы доступа
@@ -0,0 +1,33 @@
## Purpose
Определяет получение справочных сведений и результатов поиска инструментов непосредственно от источника без самостоятельной долговременной копии, отдельно от личных данных и рыночной истории.
## ADDED Requirements
### Requirement: Справочные сведения и поиск запрашиваются онлайн
При чтении карточки акции или облигации и выполнении поиска система MUST получать справочные сведения инструмента, включая доступные название, тикер, ISIN, описание и показатели компании, онлайн, а не из отдельной долговременной сохранённой копии. Повторное чтение открытой страницы MUST запрашивать эти сведения снова. Недоступность источника MUST отображаться как ошибка соответствующего справочного блока, не скрывая доступные сохранённые позиции, операции, котировки, свечи и события. Подписи в последнем успешном снимке портфеля MAY использоваться как fallback для идентификации инструмента; они MUST NOT представляться как успешно полученные сейчас подробные сведения. Для расчёта будущих событий MAY запрашиваться необходимые справочные данные без их отдельного хранения.
#### Scenario: Повторное открытие карточки акции
- **WHEN** пользователь повторно открывает страницу акции после прежнего успешного чтения
- **THEN** справочные сведения запрашиваются онлайн заново, а сохранённая копия карточки не выдаётся за актуальный ответ
#### Scenario: Источник справочника недоступен
- **WHEN** подробные сведения акции получить не удалось, но доступен последний сохранённый снимок портфеля
- **THEN** справочный блок показывает локальную ошибку, а сохранённые позиции и их доступные подписи остаются видимы без обещания актуальности подробных сведений
### Requirement: Рыночные и событийные данные имеют отдельную политику
Система MUST продолжать сохранять котировки и запрошенные диапазоны свечей; при открытии страницы обновление текущей котировки и незавершённой свечи MUST выполняться не чаще одного раза в час. Будущие дивиденды, купоны и другие поддерживаемые события MUST сохраняться и обновляться в составе часового обхода бумаг портфеля. Полностью загруженные прошлые диапазоны свечей MUST NOT повторно загружаться автоматически. Получение этих данных MUST NOT запускать синхронизацию личных счетов при открытии страницы.
#### Scenario: Свежая котировка
- **WHEN** пользователь повторно открывает страницу инструмента менее чем через час после получения котировки
- **THEN** система использует сохранённую котировку без преждевременного повторного запроса цены
#### Scenario: Исторический диапазон свечей открыт впервые
- **WHEN** пользователь открывает диапазон свечей, которого ещё нет локально
- **THEN** система загружает его для просмотра, не начиная синхронизацию личного портфеля

Some files were not shown because too many files have changed in this diff Show More