Compare commits

...

14 Commits

Author SHA1 Message Date
Andrey Epifancev
0e95f95c71 docs(openspec): change intercompany-matrix-presets + README (ревью #141)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:08:08 +04:00
Andrey Epifancev
367a11ed38 chore(api): format, mypy и тест версии правил; убрать мёртвое as_of (ревью #141)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:04:38 +04:00
Andrey Epifancev
a8e510e402 Merge origin/main в add-preset-shahmatka (ревью #141) 2026-10-21 14:03:12 +04:00
aep
57f8032dbe Merge pull request 'Фронт в arm-core/web: CLAUDE.md и README фронта' (#149) from feat/148-claude-md-frontend into main 2026-10-21 09:30:51 +03:00
Andrey Epifancev
0b8fbeca6f docs(openspec): UI-половины архивов statement-template-lines и console-files из arm-web (суффикс -web); issue arm-web → #150–#157 (#148)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:58:55 +04:00
Andrey Epifancev
a2b0a58fd0 docs(openspec): спеки экранов из arm-web@de4e477 перенесены в arm-core (#148)
Спеки consolidated-osv-ui, consolidation-setup, document-review-ui, primary-files-ui
и архив changes demo-prototype, statements-v2. Каталоги архива statement-template-lines
и console-files в arm-core уже заняты другими изменениями и не переносились.
Ссылки на ../arm-web/openspec в CLAUDE.md и web/CLAUDE.md заменены на openspec/specs/*-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:58:02 +04:00
Andrey Epifancev
7e05cdedf6 docs(web): README, CLAUDE.md и ARCHITECTURE.md фронта — из arm-web в arm-core/web (#148)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:52:56 +04:00
Andrey Epifancev
7d23097004 docs(CLAUDE.md): фронт ведётся в arm-core/web, arm-web заморожен (#148)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:45:51 +04:00
aep
3157a15921 Merge pull request 'fix(web): синхронизация встроенного фронта с arm-web@test (de4e477) (#146)' (#147) from feat/146-web-sync-de4e477 into main 2026-10-20 23:08:45 +03:00
Andrey Epifancev
52b813617a fix(web): синхронизация встроенного фронта с arm-web@test (de4e477) (#146)
Перенесён диапазон arm-web a3c0010..de4e477 (1 коммитов, 4 файлов набора
синхронизации) скриптом docs/scripts/sync-web.sh. После переноса web/ и
arm-web@test идентичны по набору. Коммиты arm-web:
- e202711 fix(DocumentReview): просмотр ОСВ как в 1С — строки счетов и субсчетов, без строк-показателей (#51, AURORA-1702, AURORA-1703)

Co-Authored-By: sync-web.sh <noreply@aepif.ru>
2026-10-21 00:08:27 +04:00
aep
5512365b5d Merge pull request 'test: golden fixtures контракта 2.0 в тестах ядра (#144)' (#145) from feat/144-golden-v2-tests into main 2026-10-20 21:12:04 +03:00
Andrey Epifancev
7dd7d5dd40 test: golden fixtures контракта 2.0 в тестах ядра (#144)
fixtures #26/#27 (19.10.2026) перевели снимки форм и OCR на контракт 2.0,
а загрузчики тестов читали их моделью 1.x: 164 падения на main.

- golden_result/ocr_result: модель по MAJOR schema_version, для 2.0 —
  service.statements_version копии пакета ядра;
- golden_result_v1: снимок 2.0 → 1.2 (строки с кодом, колонки с 0, без
  null в шапке) для модулей приёма 1.x — test_mapping_statements и
  test_api_statements; путь 2.0 покрывают *_v2;
- make_entries_from_result — parse_result; ожидания fixtures_marker под
  эталоны 2.0; удаление файла считает замечания сервиса снимка;
- 76.Н.1 (synthetic s42, tabular-parser#59) — в KNOWN_OUT_OF_PLAN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:34:20 +04:00
aep
e6e4cd877b Merge pull request 'fix(statements): вставка document_issue пакетами (#142, AURORA-1711)' (#143) from feat/142-batch-document-issues into main 2026-10-20 19:12:26 +03:00
Andrey Epifancev
01e1f81516 fix(statements): вставка document_issue пакетами по BATCH (#142, AURORA-1711)
add_issues вставлял все записи документа одним INSERT: 16 параметров на
запись, лимит asyncpg 32 767 — потолок около 2 047 замечаний. Лист ОСВ
«11.xlsx» (сч. 11, 2 128 строк «Кол. доп.» на парсере контура 40287e1)
давал 2 131 предупреждение, маппинг падал с InterfaceError, файл ERROR.
Теперь пакеты по BATCH (1000), как у строк и значений форм.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:59:25 +04:00
55 changed files with 1962 additions and 139 deletions

View File

@ -178,6 +178,12 @@
- ORM 1:1 с `../docs/model/data-model-v5.19.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - ORM 1:1 с `../docs/model/data-model-v5.19.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и
бэкенд. Отдельный репозиторий `RSHB/arm-web` заморожен на `test` `de4e477` (= arm-core `3157a15`), синхронизация
`make -C ../docs sync-web` больше не используется; правило «`web/` напрямую не править» отменено. Проверки фронта —
`make web-check` (biome + tsc + build, входит в `check-full`) и из `web/`: `npm test` (vitest), `npm run lint`
(ESLint + Prettier), форматирование — `npm run format`. Спеки экранов — `openspec/specs/*-ui` (перенесены из arm-web@de4e477);
новые и изменённые требования к экранам — в `openspec/changes/` этого репозитория.
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.
- `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh` (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`). - `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh` (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`).
- OpenSpec: `openspec/` здесь; сквозные изменения — change в `../docs`. - OpenSpec: `openspec/` здесь; сквозные изменения — change в `../docs`.

View File

@ -360,7 +360,7 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear
## Администрирование и экран /admin ## Администрирование и экран /admin
Бэкенд — `/api/v1/admin/*`, все маршруты требуют `USERS_ADMIN` (без права — 403 до любых проверок тела и существования Бэкенд — `/api/v1/admin/*`, все маршруты требуют `USERS_ADMIN` (без права — 403 до любых проверок тела и существования
объектов). Фронтенд — страница `/admin` («Пользователи и роли» в боковом меню; видна только с `USERS_ADMIN`): таблица объектов). Фронтенд — страница `/admin` («Пользователи и роли» в боковом меню; пункт пока виден всем ролям): таблица
пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает
«Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер). «Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер).
@ -374,7 +374,7 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear
make migrate && make seed-dev # схема, справочники и dev-пользователи make migrate && make seed-dev # схема, справочники и dev-пользователи
make up # если инфраструктура ещё не поднята make up # если инфраструктура ещё не поднята
cd api && .venv/bin/python run_server.py # бэкенд, :8000 (в api/.env — DEBUG=true и AUTH_DEV_ISSUER=true) cd api && .venv/bin/python run_server.py # бэкенд, :8000 (в api/.env — DEBUG=true и AUTH_DEV_ISSUER=true)
cd web && npm install && npm run start # фронтенд (rsbuild dev), откроется браузер cd web && npm install && REACT_APP_ROOT_PATH=/ npm run start # фронтенд (rsbuild dev, :3000), откроется браузер
``` ```
Вход — dev-форма «Вход для разработки» (`web/src/auth/DevLogin.tsx`): введите `admin@armka.local`; форма вызывает Вход — dev-форма «Вход для разработки» (`web/src/auth/DevLogin.tsx`): введите `admin@armka.local`; форма вызывает
@ -870,7 +870,14 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица `GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
`GET …/intercompany-matrix/result-table`. Контракт для фронта — `GET …/intercompany-matrix/result-table`. Режим PRESET (счета и показатель из правила элиминации):
`GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары
`…/preset/{rule_id}/unpaired-osv`; в ячейке `correction_ids_debit/credit` (применённые корректировки правила).
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
версии правил — открытые правила (`valid_to IS NULL`). Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель»,
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам —
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях `../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
Расчёт читает сводную ОСВ версии, результат не хранится. Расчёт читает сводную ОСВ версии, результат не хранится.
@ -1155,7 +1162,7 @@ api/
Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy
(`ensure_version_editable`, §10) (`ensure_version_editable`, §10)
tests/ pytest (юнит и integration/fixtures по маркерам) tests/ pytest (юнит и integration/fixtures по маркерам)
web/ фронтенд (React 18 + TypeScript, rsbuild, antd): api/ (клиент), auth/ (вход, профиль), layouts/Admin (/admin); сборка web/dist -> api/web/ web/ фронтенд (React 18 + TypeScript, rsbuild, antd; ведётся здесь с 21.10.2026, см. web/README.md, web/CLAUDE.md): api/ (клиент), auth/, services/api/, store/, layouts/ (страницы), widgets/ (экраны); сборка web/dist -> api/web/
setup/ бутстрап шаблона через Nexus банка (pip.conf, setup.sh) setup/ бутстрап шаблона через Nexus банка (pip.conf, setup.sh)
openspec/ спецификации и изменения (OpenSpec) openspec/ спецификации и изменения (OpenSpec)
docs/superpowers/ планы реализации docs/superpowers/ планы реализации

View File

@ -14,9 +14,7 @@ async def list_rules_with_accounts(
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
"""Правила заданной версии либо все правила без `valid_to`.""" """Правила заданной версии либо все правила без `valid_to`."""
condition = ( condition = (
EliminationRule.version_no == version_no EliminationRule.version_no == version_no if version_no is not None else EliminationRule.valid_to.is_(None)
if version_no is not None
else EliminationRule.valid_to.is_(None)
) )
stmt = ( stmt = (
select(EliminationRule, EliminationRuleAccount) select(EliminationRule, EliminationRuleAccount)

View File

@ -59,7 +59,8 @@ __all__ = [
"value_flags", "value_flags",
] ]
# строк на один INSERT: у строки формы 15 колонок, у значения 10; 15 × 1000 < 32767 параметров asyncpg # строк на один INSERT: у строки формы 15 колонок, у значения 10, у `document_issue` 16; 16 × 1000 < 32767 параметров
# asyncpg
BATCH = 1000 BATCH = 1000
@ -151,34 +152,30 @@ async def insert_statement(
async def add_issues( async def add_issues(
session: AsyncSession, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now: datetime.datetime session: AsyncSession, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now: datetime.datetime
) -> None: ) -> None:
"""Записи `document_issue` документа из черновиков (без commit).""" """Записи `document_issue` документа из черновиков пакетами по `BATCH` (без commit)."""
if not drafts: issues = [
return {
await session.execute( "id": uuid.uuid4(),
insert(DocumentIssue).values( "document_id": document_id,
[ "kind": draft.kind,
{ "code": draft.code,
"id": uuid.uuid4(), "severity": draft.severity,
"document_id": document_id, "message": draft.message,
"kind": draft.kind, "line_ids": [str(line_id) for line_id in draft.line_ids] or None,
"code": draft.code, "column_id": draft.column_id,
"severity": draft.severity, "column_index": draft.column_index,
"message": draft.message, "codes": list(draft.codes) or None,
"line_ids": [str(line_id) for line_id in draft.line_ids] or None, "field": draft.field,
"column_id": draft.column_id, "source": draft.source,
"column_index": draft.column_index, "relation": draft.relation,
"codes": list(draft.codes) or None, "expected": draft.expected,
"field": draft.field, "actual": draft.actual,
"source": draft.source, "created_at": now,
"relation": draft.relation, }
"expected": draft.expected, for draft in drafts
"actual": draft.actual, ]
"created_at": now, for start in range(0, len(issues), BATCH):
} await session.execute(insert(DocumentIssue).values(issues[start : start + BATCH]))
for draft in drafts
]
)
)
async def delete_issues( # noqa: PLR0913 - фильтры записей независимы async def delete_issues( # noqa: PLR0913 - фильтры записей независимы

View File

@ -340,7 +340,6 @@ class EliminationRulesOut(BaseModel):
version_no: int | None version_no: int | None
is_version_fixed: bool is_version_fixed: bool
as_of: datetime.date | None
rules: list[EliminationRuleOut] rules: list[EliminationRuleOut]

View File

@ -151,9 +151,7 @@ class ConsolidatedOsvService:
"""Свод версии и данные его расчёта; версия вне видимости — 404.""" """Свод версии и данные его расчёта; версия вне видимости — 404."""
return await self.build_with_members(version_id) return await self.build_with_members(version_id)
async def build_with_members( async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult:
self, version_id: uuid.UUID
) -> ConsolidatedOsvBuildResult:
"""Свод и участники периметра, загруженные при его расчёте.""" """Свод и участники периметра, загруженные при его расчёте."""
found = await repo.get_version(self._session, self._scope, version_id) found = await repo.get_version(self._session, self._scope, version_id)
if found is None: if found is None:
@ -224,9 +222,8 @@ class ConsolidatedOsvService:
"rows": len(rows), "rows": len(rows),
}, },
) )
filename = ( period = f"{result.consolidation.year}-{result.consolidation.quarter.value}"
f"svod-osv_{group_name}_{result.consolidation.year}-{result.consolidation.quarter.value}_v{result.version.version_no}.xlsx" filename = f"svod-osv_{group_name}_{period}_v{result.version.version_no}.xlsx"
)
return content, filename return content, filename
async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut:

View File

@ -54,6 +54,5 @@ class EliminationRuleService:
return EliminationRulesOut( return EliminationRulesOut(
version_no=version_no, version_no=version_no,
is_version_fixed=is_version_fixed, is_version_fixed=is_version_fixed,
as_of=None,
rules=list(by_id.values()), rules=list(by_id.values()),
) )

View File

@ -497,7 +497,7 @@ class IntercompanyMatrixService:
raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон") raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон")
debit = _select_accounts(debit_ids, debit_excluded, "D", by_id, children) debit = _select_accounts(debit_ids, debit_excluded, "D", by_id, children)
credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children) credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children)
measure = "closing" if rule.measure == "closing" else "turnover" measure: Measure = "closing" if rule.measure == "closing" else "turnover"
return MatrixSelection(debit, credit, measure), rule return MatrixSelection(debit, credit, measure), rule

View File

@ -28,7 +28,8 @@ from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.contract import request_subject from src.contract import request_subject
from src.contract.result import OsvDocument, RecognitionResult from src.contract.result import OsvDocument
from src.contract.versions import parse_result
from src.domain.consolidation_periods import reporting_date from src.domain.consolidation_periods import reporting_date
from src.domain.osv_mapping import build_entries, document_fields from src.domain.osv_mapping import build_entries, document_fields
from src.domain.policy import FINISHED_STATUSES from src.domain.policy import FINISHED_STATUSES
@ -349,7 +350,7 @@ async def make_entries_from_result(
assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах
payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)} payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)}
payload["service"] = {"version": "test", **result_json.get("service", {"name": "tabular-parser"})} payload["service"] = {"version": "test", **result_json.get("service", {"name": "tabular-parser"})}
result = RecognitionResult.model_validate(payload) result = parse_result(json.dumps(payload).encode()) # модель — по MAJOR `schema_version` (снимки 1.x и 2.0)
sheets = [doc for doc in result.documents if isinstance(doc, OsvDocument)] sheets = [doc for doc in result.documents if isinstance(doc, OsvDocument)]
chart = { chart = {
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))

View File

@ -6,13 +6,14 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING: if TYPE_CHECKING:
from src.contract.result import RecognitionResult from src.contract.result import RecognitionResult
from src.contract.result_v2 import RecognitionResultV2
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import RecognitionStatus from src.models.enums import RecognitionStatus
from src.models.files import Document from src.models.files import Document
@ -44,18 +45,19 @@ def fixtures_skip_reason() -> str | None:
) )
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) """Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) как файл результата: модель — по MAJOR
как `RecognitionResult` контракта v1.2. `schema_version` эталона (без поля — v1.2, эталоны до контракта 2.0).
""" """
from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest from src.contract.versions import parse_result # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8")) data = json.loads(path.read_text("utf-8"))
version = data.get("schema_version", "1.2")
payload: dict[str, object] = { payload: dict[str, object] = {
"schema_version": "1.2", "schema_version": version,
"job_id": str(job_id), "job_id": str(job_id),
"file_id": str(file_id), "file_id": str(file_id),
"service": {"name": "scan-gateway", "version": "golden"}, "service": _golden_service({"name": "scan-gateway"}, version),
"status": data["status"], "status": data["status"],
"documents": data["documents"], "documents": data["documents"],
} }
@ -63,18 +65,68 @@ def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Recogni
payload["error"] = data["error"] payload["error"] = data["error"]
if data.get("metrics") is not None: if data.get("metrics") is not None:
payload["metrics"] = data["metrics"] payload["metrics"] = data["metrics"]
return RecognitionResult.model_validate(payload) return parse_result(json.dumps(payload).encode())
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса.""" """Golden-снимок парсера как файл результата (модель — по MAJOR `schema_version`): подставляет `job_id`/`file_id`
from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest и версию сервиса."""
from src.contract.versions import parse_result # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
data = json.loads(path.read_text("utf-8")) data = json.loads(path.read_text("utf-8"))
data["service"] = _golden_service(data["service"], data["schema_version"])
return parse_result(json.dumps(data | {"job_id": str(job_id), "file_id": str(file_id)}).encode())
def golden_result_v1(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
"""Golden-снимок как результат контракта 1.2 — для тестов маппинга и API формы на пути `1.x` (ядро принимает оба
MAJOR). Снимок 2.0 сводится к 1.2: строки без кода отброшены (в 1.x `line_code` обязателен), колонки формы
перенумерованы по порядку с 0, как отдавал сервис 1.x (в 2.0 `column_index` — номер столбца листа), предупреждения
файла — текстом; поля, которых нет в 1.x, модель отбрасывает сама."""
from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8"))
if data["schema_version"].startswith("2."):
data["schema_version"] = "1.2"
metrics = data.get("metrics")
if metrics and metrics.get("warnings") is not None:
metrics["warnings"] = [warning["message"] for warning in metrics["warnings"]]
for document in data["documents"]:
if "lines" in document:
_statement_as_v1(document)
data["service"] = {"version": "golden", **data["service"]} data["service"] = {"version": "golden", **data["service"]}
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)}) return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
def _statement_as_v1(document: dict[str, Any]) -> None:
"""Форма снимка 2.0 → 1.2 на месте: строки с кодом, колонки 0…n-1 в порядке первого появления `column_index`
(повтор номера остаётся повтором), `null` в шапке — отсутствие поля (в 1.x часть полей шапки не допускает null)."""
index: dict[int, int] = {}
for column in document["columns"]:
index.setdefault(column["column_index"], len(index))
document["header"] = {key: value for key, value in document["header"].items() if value is not None}
for column in document["columns"]:
column["column_index"] = index[column["column_index"]]
document["lines"] = [line for line in document["lines"] if line.get("line_code")]
for line in document["lines"]:
line["values"] = [value | {"column_index": index[value["column_index"]]} for value in line["values"]]
checks = document.get("checks") or {}
for key in ("relations_failed", "soft_relations_failed"):
checks[key] = [item | {"column_index": index[item["column_index"]]} for item in checks.get(key) or ()]
def _golden_service(service: dict[str, object], schema_version: str) -> dict[str, object]:
"""`service` снимка: версия сервиса `golden`, у контракта 2.0 — ещё `statements_version` копии пакета ядра (в
снимках версии вырезаны, а при формах поле обязательно; совпадение с ядром — как у сервиса той же сборки)."""
from src.contract.versions import MAJOR_V2, major # noqa: PLC0415 - цикл с conftest
from src.services.statements_engine import default_engine # noqa: PLC0415
result: dict[str, object] = {"version": "golden", **service}
if major(schema_version) == MAJOR_V2:
result.setdefault("statements_version", default_engine().version)
return result
async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные
session: AsyncSession, session: AsyncSession,
*, *,

View File

@ -169,8 +169,9 @@ async def test_delete_file_with_statement_form(
file_id = await _form_file_with_comment(seeded, fixtures_dir) file_id = await _form_file_with_comment(seeded, fixtures_dir)
document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars()) document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars())
assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1 assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1
issues = await _count(seeded, DocumentIssue) # предупреждения сервиса из снимка 2.0
await make_document_issue(seeded, document_id=document_ids[0]) # v5.19: document_issue уходит вместе с файлом await make_document_issue(seeded, document_id=document_ids[0]) # v5.19: document_issue уходит вместе с файлом
assert await _count(seeded, DocumentIssue) == 1 assert await _count(seeded, DocumentIssue) == issues + 1
if superseded: if superseded:
await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED)) await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED))
await seeded.flush() await seeded.flush()

View File

@ -221,8 +221,8 @@ async def test_preset_uses_current_rules_when_version_unfixed(
listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert listed.status_code == OK, listed.text assert listed.status_code == OK, listed.text
assert listed.json()["is_version_fixed"] is False assert listed.json()["is_version_fixed"] is False
assert listed.json()["version_no"] == rule.version_no assert listed.json()["version_no"] is None
assert listed.json()["as_of"] is not None assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]} assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
response = await client.get(url) response = await client.get(url)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
@ -238,7 +238,6 @@ async def test_preset_uses_current_rules_when_version_unfixed(
assert fixed.status_code == OK, fixed.text assert fixed.status_code == OK, fixed.text
assert fixed.json()["is_version_fixed"] is True assert fixed.json()["is_version_fixed"] is True
assert fixed.json()["version_no"] == rule.version_no assert fixed.json()["version_no"] == rule.version_no
assert fixed.json()["as_of"] is None
missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}" missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}"
missing = await client.get(missing_url) missing = await client.get(missing_url)
assert missing.status_code == NOT_FOUND assert missing.status_code == NOT_FOUND

View File

@ -2,6 +2,7 @@
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" «Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
import datetime import datetime
import functools
import json import json
import uuid import uuid
from decimal import Decimal from decimal import Decimal
@ -51,10 +52,14 @@ from tests.factories import (
make_stored_file, make_stored_file,
) )
from tests.fake_engine import FakeEngine from tests.fake_engine import FakeEngine
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot from tests.fixtures_helpers import GOLDEN_STATEMENTS, golden_result_v1
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures] pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
# формы — из снимков, сведённых к 1.2 (модуль писался под приём `1.x`); правка формы 2.0 — `test_statements_v2_edit.py`
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
KA_RF = "ka_rf@armka.local" KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local" # только DOCUMENT_VIEW RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал" ROSTOV = "Ростовский региональный филиал"

View File

@ -33,9 +33,7 @@ def _rule() -> SimpleNamespace:
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True), (SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True),
], ],
) )
def test_rule_is_valid_for_version( def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None:
rule: SimpleNamespace | None, version_no: int | None, expected: bool
) -> None:
assert rules_repo.is_valid_for_version(rule, version_no) is expected assert rules_repo.is_valid_for_version(rule, version_no) is expected
@ -69,7 +67,6 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -
assert result.is_version_fixed is True assert result.is_version_fixed is True
assert result.version_no == rule.version_no assert result.version_no == rule.version_no
assert result.as_of is None
assert [item.id for item in result.rules] == [rule.id] assert [item.id for item in result.rules] == [rule.id]
assert result.rules[0].company_1.accounts == [account_id] assert result.rules[0].company_1.accounts == [account_id]
listed.assert_awaited_once_with(session, rule.version_no) listed.assert_awaited_once_with(session, rule.version_no)
@ -91,7 +88,6 @@ async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch)
assert result.is_version_fixed is False assert result.is_version_fixed is False
assert result.version_no is None assert result.version_no is None
assert result.as_of is None
assert [item.id for item in result.rules] == [rule.id] assert [item.id for item in result.rules] == [rule.id]
listed.assert_awaited_once_with(session, None) listed.assert_awaited_once_with(session, None)
@ -110,6 +106,5 @@ async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pyt
assert result.is_version_fixed is False assert result.is_version_fixed is False
assert result.version_no is None assert result.version_no is None
assert result.as_of is None
assert result.rules == [] assert result.rules == []
listed.assert_awaited_once_with(session, None) listed.assert_awaited_once_with(session, None)

View File

@ -46,7 +46,7 @@ def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tm
@pytest.mark.fixtures @pytest.mark.fixtures
def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None: def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
"""ocr_result читает эталон нормализатора и создаёт RecognitionResult """ocr_result читает эталон нормализатора (контракт 2.0) и создаёт файл результата
с правильным порядком документов. с правильным порядком документов.
""" """
job_id, file_id = uuid.uuid4(), uuid.uuid4() job_id, file_id = uuid.uuid4(), uuid.uuid4()
@ -54,18 +54,19 @@ def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
assert result.job_id == job_id and result.file_id == file_id assert result.job_id == job_id and result.file_id == file_id
assert result.service.name == "scan-gateway" assert result.service.name == "scan-gateway"
assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"] assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"]
assert result.status == "needs_check" and result.schema_version == "1.2" assert result.status == "needs_check" and result.schema_version == "2.0"
assert result.error is None assert result.error is None
assert result.metrics is not None and result.metrics.rows == 60 # noqa: PLR2004 - эталон aep_6 assert result.metrics is not None and result.metrics.rows == 88 # noqa: PLR2004 - эталон aep_6
@pytest.mark.fixtures @pytest.mark.fixtures
def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None: def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None:
"""Эталон с ошибкой: код и сообщение переносятся в результат, метрик нет.""" """Эталон с ошибкой: код и сообщение переносятся в результат, в метриках — предупреждение файла уровня error."""
result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4()) result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4())
assert result.status == "error" and result.documents == [] assert result.status == "error" and result.documents == []
assert result.error is not None and result.error.code == "INVALID_DOCUMENT" assert result.error is not None and result.error.code == "INVALID_DOCUMENT"
assert result.metrics is None assert result.metrics is not None and result.metrics.warnings is not None
assert [(w.code, w.severity) for w in result.metrics.warnings] == [("JSON_FAILED", "error")]
def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None: def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None:

View File

@ -57,6 +57,7 @@ KNOWN_OUT_OF_PLAN = frozenset(
"76.77.2", "76.77.2",
"76.77.5", "76.77.5",
"76.АФ", # osv-dkb/primanych "76.АФ", # osv-dkb/primanych
"76.Н.1", # synthetic/s42_subaccounts_totals_reversed (tabular-parser#59)
} }
) )
TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты

View File

@ -1,6 +1,7 @@
"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и """Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и
прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures.""" прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures."""
import functools
import json import json
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
@ -35,10 +36,14 @@ from src.models.statements import (
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file
from tests.factories import make_document_issue, make_legal_entity from tests.factories import make_document_issue, make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result, map_golden_snapshot, ocr_result from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result_v1, ocr_result
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures] pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
# модуль проверяет маппинг форм `1.x` (Срез 3b): снимки 2.0 сводятся к 1.2; путь 2.0 — `test_mapping_statements_v2.py`
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3" NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic" SYNTH = GOLDEN_STATEMENTS / "synthetic"
@ -312,9 +317,9 @@ async def test_duplicate_column_index_does_not_fail_mapping(
) )
) )
).all() ).all()
first = columns[0] first = columns[0] # снимок сведён к 1.2: колонки пронумерованы с 0, повтор номера первой колонки — тоже 0
assert sorted(i for i, _ in stored) == sorted({c["column_index"] for c in columns}) assert sorted(i for i, _ in stored) == list(range(len({c["column_index"] for c in columns})))
assert (first["column_index"], first["label"]) in stored and (first["column_index"], "дубль") not in stored assert (0, first["label"]) in stored and (0, "дубль") not in stored
file = await seeded.get(StoredFile, income.stored_file_id) file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None and file.status is FileStatus.PROCESSED assert file is not None and file.status is FileStatus.PROCESSED
assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income)) assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income))
@ -354,7 +359,7 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture
file.status = FileStatus.PROCESSING file.status = FileStatus.PROCESSING
await seeded.flush() await seeded.flush()
chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))} chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))}
result = golden_result(path, job_id=job.id, file_id=file.id) result = golden_result_v1(path, job_id=job.id, file_id=file.id)
await map_file( await map_file(
seeded, seeded,

View File

@ -527,3 +527,23 @@ async def test_osv_v2_sheet_needs_check_blocks_recognized(seeded: AsyncSession)
osv["status"] = "needs_check" osv["status"] = "needs_check"
(document,) = await _map(seeded, entity, [osv]) (document,) = await _map(seeded, entity, [osv])
assert document.status is DocumentStatus.NEEDS_CHECK assert document.status is DocumentStatus.NEEDS_CHECK
async def test_osv_v2_many_sheet_warnings_stored(seeded: AsyncSession) -> None:
"""AURORA-1711: предупреждений листа больше, чем помещается в один INSERT `document_issue` (16 параметров на
запись, лимит asyncpg 32 767 — ≈ 2 047 записей). Так было на «11.xlsx» (сч. 11, 2 128 строк «Кол. доп.»):
документ создаётся, все предупреждения сохранены."""
entity = await _entity(seeded)
osv = _osv_doc()
count = 2131
osv["warnings"] = [
{
"code": "OSV_ROWS",
"severity": "info",
"message": f"строка {row}: метка «Кол. доп.» в колонке «Показатели» не распознана",
}
for row in range(count)
]
(document,) = await _map(seeded, entity, [osv])
assert document.document_type is DocumentType.OSV
assert len(await _issues(seeded, document)) == count

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-08

View File

@ -0,0 +1,158 @@
## Context
arm-web выделен из `arm-core/web` командой фронта (3 коммита 06–07.10.2026) и дополнен экранами групп, консолидаций и
загрузки. Обзор 08.10.2026: список консолидаций и карточка версии работают с arm-core; загрузка теряет ошибки (нет
`await`, лишние поля формы); проверка документов (`PDFResultViewer`, `FileViewer`, `FileUploaderModel`) скопирована из
старого АРМ и ходит в `/fsl/...`, `/osv/by-file-id`, `POST /files` мимо клиента API; сводной ОСВ нет; `tsc` — 185 ошибок,
biome — 276.
Демо — 09.10.2026 на локальном стенде; затем прототип уходит в контур на тестирование заказчику и переезжает в
`arm-core/web`, где развивается дальше. Поэтому код держится соглашений шаблона `arm-core/web`.
Источник контракта — `../docs/dev/frontend-api-guide.md` (§5.5–§5.7) и схемы `arm-core/api/src/schemas/`.
## Goals / Non-Goals
**Goals:** аналитик на стенде проходит путь «создание консолидации → загрузка ОСВ и PDF → проверка и подтверждение →
сводная ОСВ» без сбоев и консоли разработчика; `make check` зелёный; все запросы — к API arm-core.
**Non-Goals:** см. proposal.md («Non-goals»).
## Decisions
### D-1. Чинить на месте, новое — по образцу arm-core/web (согласовано 08.10.2026)
Оставляем `api/client.ts`, `auth/*`, список консолидаций, карточку версии, модалку создания (с правками), Redux-слайсы
(типизируем). Новые экраны держат состояние в локальных хуках (`useStatement`, `useDocumentEntries`,
`useConsolidatedOsv`) как `arm-core/web/src/widgets/Admin/hooks.ts`. Альтернативы — генерация типов из OpenAPI (новая
dev-зависимость, доступность в Nexus не проверена) и переписывание с нуля (не успеть) — отклонены владельцем.
### D-2. Типы DTO руками
`src/types/` повторяют схемы arm-core только для используемых эндпоинтов: `Page<T>`, `UploadResult`, `FileOut`,
`DocumentBrief`, `DocumentOut`, `StatementOut` (`HeaderOut`, `ColumnOut`, `LineOut`, `ValueOut`, `ChecksOut`),
`EntryOut`, `ConsolidatedRowOut`, `OsvSummaryOut`, `VersionCard` (исправления: `is_head_entity: boolean`,
`entity_type: 'ЮЛ' | 'ИП' | null`, `osv_accounts: string[]`, `changed_by` — UUID). `Decimal` и даты приходят строками —
в типах `string`.
### D-3. Деньги — строки
Суммы не превращаются в `number`. Чистые функции `src/utils/money.ts`: `formatAmount(s)` — разделители разрядов и два
знака без арифметики над float; `parseAmountInput(s)` — нормализация ввода (пробелы, запятая → точка) и проверка
`^-?\d+(\.\d{1,2})?$`, пустое — `null`. Единицы ББ/ОФР — из `header.units`, ОСВ — рубли.
### D-4. Бинарные ответы — `apiFetchBlob`
`src/api/client.ts` получает `apiFetchBlob(path) → {blob, filename | null}` с той же обработкой токена, 401 и ошибок
(тело ошибки — JSON). Используется для скана (`blob.arrayBuffer()` → pdf.js) и экспорта (сохранение через
`URL.createObjectURL` + `<a download>`). Имя экспорта — из `Content-Disposition` (`filename*=UTF-8''…` или `filename=`),
иначе `exportFilename(group, year, quarter)`.
### D-5. Dev-proxy вместо CORS
`rsbuild.config.ts`: `server.proxy['/api'] → process.env.ARM_CORE_URL ?? 'http://localhost:8000'`; в dev
`REACT_APP_API_URL` пустой. Запросы идут с того же источника: нет CORS и читается `Content-Disposition` (follow-up
docs#58 O-1 на фронте обходится). В контуре SPA отдаёт сам arm-core — тот же источник, код не меняется. Гайд фронта §2
(где `REACT_APP_API_URL=http://localhost:8000`) остаётся верным для варианта без proxy.
### D-6. Просмотр PDF
Из `widgets/PDFResultViewer/IFrameBlock.tsx` сохраняется только постраничный рендер pdf.js (canvas, масштаб, листание);
загрузка — `apiFetchBlob('/files/{id}/content?disposition=inline')` → `arrayBuffer`. Компонент переименовывается в
`widgets/DocumentReview/PdfPages.tsx`. Worker pdf.js подключается из пакета `pdfjs-dist` сборкой (без CDN — в контуре
внешнего интернета нет). `PDFDataViewer.tsx`, `types.ts`, `styles.module.css` старого АРМ удаляются.
### D-7. Экран документа
`layouts/DocumentPage.tsx` (`/documents/:document_id`) → `GET /documents/{id}` → `widgets/DocumentReview/StatementReview`
(BALANCE/INCOME) или `widgets/DocumentReview/OsvDocument` (OSV). Чистые функции: `buildStatementGrid(statement)` —
матрица строк × колонок по `column_id` (не по индексу), `buildEntryTree(entries)` — дерево по `parent_entry_id` с
сохранением `row_number`. Правка ячейки — локальный черновик ячеек и «Сохранить» одним `PATCH` со всеми изменёнными
ячейками; ответ сервера заменяет состояние (сервер пересчитывает проверки и сбрасывает акцепт).
### D-8. Панель файлов
`widgets/FilesDrawer` переписывается: `UploadDocumentsModal` → один запрос со всеми файлами, итог из
`summarizeUpload(result)`; список — `FilesList` с вложенными документами; подписи — `fileStatusLabel`,
`documentTypeLabel`, `periodLabel`, `documentStatusLabel` (чистые функции). Опрос — `useDocumentsPolling` с остановкой,
когда нет `UPLOADED`/`PROCESSING`. Слайс `files` типизируется или заменяется локальным хуком (решает исполнитель по
месту, без смены поведения).
### D-9. Сводная ОСВ
`layouts/ConsolidatedOsvPage.tsx` + `widgets/ConsolidatedOsv/` (`useConsolidatedOsv`, `OsvFilters`, `OsvTable`,
`OsvGaps`). Таблица — antd `Table` с серверной пагинацией (`limit` 500). Фильтры — query-параметры как в API; ЮЛ —
из `VersionCard.members`.
### D-10. Тесты — vitest на чистых функциях
`vitest` (devDependency), `npm test` → `vitest run`, цель `make test` входит в `make check`. Покрытие по TDD: money,
`summarizeUpload`, подписи статусов/типов/периодов, `normalizeData` карточки версии, `exportFilename` и разбор
`Content-Disposition`, `buildStatementGrid`, `buildEntryTree`. Компоненты — ручной демо-сценарий.
## Демо-сценарий (приёмка)
Стенд по `../docs/dev/qa-guide.md` §4 и §6.8 (профиль `services`: tabular-parser, scan-gateway, scan-emulator),
`make seed-dev`, вход `ka_rf@armka.local` (или пользователь из гайда с правами EDIT на ГК «Русский бисквит»).
Данные стенда уже подготовлены: консолидация РБ ГСК 2024 Q3 создана (версия `0437f8be-019c-4dbb-9776-08c306aa38cc`),
`aep_6.pdf` уже загружен Счастливочной; повторы дают штатные 409 `CONSOLIDATION_EXISTS` / `DUPLICATE_FILE`.
1. Кнопка «Новая консолидация» — в боковом меню (действует на всё приложение); ГК «Русский бисквит» выбирается в модалке:
ГСК, период 2024 Q2 или 2025 Q3 (для 2024 Q3 — показать 409 как штатную ошибку) → члены ГСК отмечены → «Создать».
2. Шаги 2–4 — на подготовленной версии `0437f8be-…`. Панель «Организации» → РБ → «Файлы»: ОСВ
`fixtures/osv/rb/2024-Q{1,2,3}/*` и Счастливочной (`fixtures/osv/schastl/2024-Q{1,2,3}/*`) загружены, документы
подтверждены (на новой версии — загрузить и подтвердить на экране ОСВ).
3. ББ/ОФР: открыть уже загруженный ББ Счастливочной (или загрузить `fixtures/ocr/aep_6.pdf` РБ) → скан слева, форма справа,
исправить ячейку; ИНН и дата отчёта не распознаны — ввести в шапке дату `2024-09-30` (ГГГГ-ММ-ДД) и «Сохранить», иначе
409 `PERIOD_REQUIRED`; принять нарушения с комментарием (если есть), подтвердить.
4. «К отчётности» → сводная ОСВ: 1385 строк, 22 документа, 7 пробелов; фильтр Счастливочная + «бисквит» → строка `60.01`
по `qa-guide` §7.2; «Скачать xlsx».
Шаги API (2–4) перед браузерным прогоном проверяются curl-скриптом; браузерный прогон — владелец.
## Risks / Trade-offs
- [Круг «ядро + scan-gateway + эмулятор» на стенде целиком не прогонялся (`qa-guide` §6.8: «сервис проверяется
отдельно»)] → Задача 1 плана — прогон до кода. Если сломан: issue в arm-core/scan-gateway, а форма ББ/ОФР на демо —
из xlsx (`fixtures/statements-xlsx/mezheninovskaya`, `qa-guide` §6.9), скан — заглушка «превью недоступно».
- [Объём к завтра] → порядок задач от критичного к желательному; экспорт и пробелы — последними.
- [Ручные типы расходятся с API] → типы только по используемым полям, сверка со схемами при ревью.
- [Подтверждение 22 ОСВ кликами долго на демо] → в README — скрипт подтверждения из `qa-guide` §7.1; на демо можно
показать подтверждение одного документа, остальные подготовить заранее.
## Migration Plan
Один PR `feat/3-demo-prototype` → `test`. Откат — revert PR. Перенос в `arm-core/web` — отдельная задача после демо.
## Open Questions
- Какой ЮЛ «владеет» `aep_6.pdf` в фикстурах (ИНН в шапке) — влияет только на `counterparty_check` (не блокирует
подтверждение); выясняется в задаче 1.
## Decisions made without discussion
- 08.10.2026 — D-5 dev-proxy `/api` вместо `REACT_APP_API_URL=http://localhost:8000`. Обоснование: снимает CORS и
нечитаемый `Content-Disposition` без правок arm-core. Цена ошибки: низкая — переменная `REACT_APP_API_URL` продолжает
работать, proxy только в dev.
- 08.10.2026 — D-6 worker pdf.js из пакета, без CDN. Обоснование: в контуре нет внешнего интернета. Цена ошибки: низкая.
- 08.10.2026 — D-7 правка ячеек пакетом по «Сохранить», а не по каждой ячейке. Обоснование: меньше запросов, сервер
принимает до 5000 ячеек и пересчитывает проверки один раз. Цена ошибки: низкая — поведение меняется в одном хуке.
- 08.10.2026 — D-8: слайс Redux `files` заменён локальным хуком `useLegalEntityFiles` (список нужен только открытой панели). Цена ошибки: низкая.
- 08.10.2026 — R-1: Task 1 (проверка стенда, без записи в worktree) и Task 2 запущены параллельно. Обоснование: срок демо. Цена ошибки: нулевая.
- 08.10.2026 — R-2: замечание T4.I1 (частичный успех загрузки не обновляет список) отложено в Task 6, где модалка переписывалась; закрыто в Task 6. Цена ошибки: нулевая.
- 08.10.2026 — R-3, R-4: scoped re-review однострочного фикса T5.I1 и фикса T7.I1 (чистая функция с тестами) заменены проверкой контроллера по коду. Цена ошибки: низкая.
- 08.10.2026 — R-5: все находки финального ревью ветки исправлены одной fix-волной (384301e, 0f734e3, 67bc2c8), M-5 (тосты после 401) отложена. Цена ошибки: низкая.
- 08.10.2026 — R-6: `make start` задаёт `REACT_APP_ROOT_PATH=/` (иначе белый экран на dev-сервере, C-1). Цена ошибки: низкая — в контуре переменную задаёт платформа.
- 08.10.2026 — R-7: по замечаниям владельца (H-1, H-2) и сверке всех экранов с PNG макета (`docs/mockup/screens/`) экраны
[03], [30], [34], [39], [40], [41], [42] приведены к макету в той же ветке. Цена ошибки: низкая — только вёрстка.
- 08.10.2026 — R-8: суммы — запятая и разделители разрядов (`12 275,68`); в своде и ОСВ ноль показывается «—», в формах
ББ/ОФР — без копеек и «-» для пустых (как в макете). Цена ошибки: низкая — одна функция `formatAmount`.
- 08.10.2026 — R-9: даты загрузки и создания показываются по Москве (`Europe/Moscow`), а не по UTC-строке. Цена: низкая.
- 08.10.2026 — R-10: [34] «Дата верификации» — «—» (в `VersionBrief` нет поля, до маршрута согласования), «ГСК» — «—»
(П-3). Цена: низкая.
- 08.10.2026 — R-11: [30] вкладки Шахматка/КФО/… показаны неактивными (Срезы 5–6); фильтры свода — в заголовках колонок,
признак контрагента — в фильтре «Контрагенты». Цена: низкая.
- 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный
список нарушений — с прокруткой (120 px). Цена: низкая.
- 08.10.2026 — R-13 (согласовано с владельцем в чате): форма ББ/ОФР [41] раскладывается «как на бумаге» на фронте —
порядок строк и заголовки разделов восстанавливаются по соотношениям §7 (`formLayout.ts`), итоги XX00 жирные (кроме 2900),
строки вне формы — «Прочие строки»; приклеенные OCR заголовки разделов срезаются только при показе (полное имя — в
подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина:
сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются;
при появлении порядка строк в API (issue arm-core) раскладку можно упростить.
- 08.10.2026 — R-14 (по замечанию владельца H-5): шапка формы [41] — как в старом АРМ (`reference-old-arm`
`PDFResultViewer`): компактная таблица ИНН / Дата отчёта / Единица измерения (подсветка уверенности и правки, правка в
режиме «Редактировать»), затем «Бухгалтерский баланс на …». Реквизиты бумажной шапки макета (ОКПО, ОКОПФ, ОКФС, адрес,
название организации) не показываются: распознавание их не отдаёт или отдаёт мусор (arm-scan-gateway#15). Заменяет
шапку из R-13. Цена ошибки: низкая — один компонент `StatementHeaderBlock`.

View File

@ -0,0 +1,57 @@
## Why
В пятницу 09.10.2026 — демо владельцу и бизнесу, затем прототип уходит в контур банка на тестирование заказчику
(и переезжает внутрь `arm-core/web`). Сейчас arm-web не проводит аналитика по пути «консолидация → первичка →
проверка → сводная ОСВ»: загрузка теряет ошибки, проверка документов ходит в эндпоинты старого АРМ, экрана сводной ОСВ
нет, `make check` красный (tsc — 185 ошибок, biome — 276).
## What Changes
- Модалка «Новая консолидация» [42]: предотметка членов ГСК, догрузка всех ЮЛ ГК, ошибки API, переход к новой версии.
- Панель «Файлы ЮЛ» [39]/[40] переписана: загрузка в контексте версии с итогом «принято / отклонено», файлы со
вложенными документами, переход к документу, опрос до окончания распознавания.
- Новый экран документа `/documents/:document_id`: для ББ/ОФР [41]/[41-02] — скан PDF рядом с формой, правка ячеек,
проверки и их акцепт, подтверждение; для ОСВ — дерево строк только для чтения и подтверждение.
- Новый экран «Сводная ОСВ» [30] `/groups/:group_id/versions/:version_id/osv`: таблица, фильтры, пробелы, счётчик
несопоставленных, экспорт xlsx.
- `api/client.ts`: `apiFetchBlob` для бинарных ответов (скан, xlsx); dev-proxy `/api` в `rsbuild.config.ts`.
- **BREAKING (внутреннее):** удалены `layouts/FileViewer.tsx`, `components/FileUploaderModel.tsx`,
`widgets/PDFResultViewer/PDFDataViewer.tsx`, `services/api/mocks.ts`; маршрут `/files/:file_id` заменён на
`/documents/:document_id`.
- `make check` зелёный: biome + tsc + vitest + build; добавлен `vitest` (dev) для чистых функций.
## Capabilities
### New Capabilities
- `consolidation-setup`: создание консолидации ГК за период из модалки и переход к её версии.
- `primary-files-ui`: загрузка первичных файлов ЮЛ в контексте версии и список файлов/документов со статусами.
- `document-review-ui`: проверка и подтверждение документа — форма ББ/ОФР со сканом PDF, ОСВ-документ.
- `consolidated-osv-ui`: сводная ОСВ версии — просмотр, фильтры, пробелы, экспорт.
### Modified Capabilities
(нет — `openspec/specs/` пуст)
## Impact
- Экраны макета: [03], [34], [39], [40], [41], [41-02], [42], [30].
- Эндпоинты arm-core (`/api/v1`), только существующие:
`GET /directories/company-groups`, `GET /directories/company-groups/{id}/related-groups`,
`GET /directories/related-groups/{id}`, `GET /directories/legal-entities`, `POST /consolidations`,
`GET /company-groups/{id}/consolidations`, `GET /consolidations/versions/{id}`,
`POST /legal-entities/{id}/files`, `GET /legal-entities/{id}/files`, `GET /files/{id}/content`,
`GET /documents/{id}`, `GET /documents/{id}/entries`, `POST /documents/{id}/confirm`,
`GET|PATCH /documents/{id}/statement`, `POST /documents/{id}/statement/checks/accept`,
`GET /consolidations/versions/{id}/osv`, `GET …/osv/summary`, `GET …/osv/export`.
- Зависимости: `vitest` (devDependency).
- Код: `src/api/`, `src/services/api/`, `src/types/`, `src/widgets/{NewConsoleModal,FilesDrawer,ConsoleTable,…}`,
новые `src/widgets/DocumentReview/`, `src/widgets/ConsolidatedOsv/`, `src/layouts/`, `src/App.tsx`, `rsbuild.config.ts`,
`Makefile`, `README.md`.
## Non-goals
- Блокировка версии («Взять в работу»), сопоставление контрагентов [32-*], экран чек-листа [37], комментарии,
удаление файлов, смена периметра и ручные переходы статусов.
- Правка строк ОСВ (в API нет), картинки страниц и координаты ячеек в скане (в API нет).
- Генерация типов из OpenAPI; автотесты компонентов.
- Перенос в `arm-core/web` и сборка под контур — отдельной задачей после демо.
- Изменения arm-core / scan-gateway: найденный пробел — отдельная issue в том репозитории.

View File

@ -0,0 +1,39 @@
## ADDED Requirements
### Requirement: Сводка версии
Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку
`GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик
«Осталось N» без перехода к сопоставлению), автоматически сопоставленных.
#### Scenario: Контрольные цифры РБ 2024 Q3
- **WHEN** открыт свод версии РБ 2024 Q3 после подтверждения 22 ОСВ из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}`
- **THEN** «строк» = 1385, «документов» = 22, «несопоставленных» = 1381
### Requirement: Таблица свода и фильтры
Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт,
контрагент, ИНН контрагента, признак контрагента (ГСК/ГК/Внешний), способ сопоставления, сальдо на начало Дт/Кт,
обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов). Фильтры: ЮЛ (`legal_entity_id`),
корень счёта (`account_root`), признак контрагента (`counterparty_type`), только несопоставленные (`unmapped`), поиск по
контрагенту (`q`, ≤ 500 символов). Смена фильтра SHALL перечитывать первую страницу.
#### Scenario: Строка из контрольных цифр
- **WHEN** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит»
- **THEN** строка `60.01` показывает сальдо нач. Кт 4 454 151.93, обороты Дт 128 058 143.65, Кт 125 950 450.77,
сальдо кон. Кт 2 346 459.05, признак «ГСК»
### Requirement: Пробелы
Экран SHALL показывать `gaps[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в
своде отсутствуют.
#### Scenario: Пробелы РБ 2024 Q3
- **WHEN** открыт свод версии РБ 2024 Q3
- **THEN** показано 7 пробелов, у каждого недостающие кварталы «Q1, Q2»
### Requirement: Экспорт
Кнопка «Скачать xlsx» SHALL получать `GET /consolidations/versions/{id}/osv/export` с текущими фильтрами через клиент API
с токеном и сохранять файл; имя — из `Content-Disposition`, а если заголовок недоступен —
`Сводная_ОСВ_<ГК>_<год>_<квартал>.xlsx`.
#### Scenario: Экспорт с фильтром
- **WHEN** аналитик скачивает свод с фильтром по ЮЛ
- **THEN** сохраняется xlsx, а ошибка сервера показывается текстом, а не скачивается

View File

@ -0,0 +1,39 @@
## ADDED Requirements
### Requirement: Состав модалки «Новая консолидация»
Модалка [42] SHALL предлагать выбор ГК (`GET /directories/company-groups`), ГСК этой ГК с `kind = gsk`
(`GET /directories/company-groups/{id}/related-groups`), года и квартала, и показывать **все** незакрытые ЮЛ ГК
(`GET /directories/legal-entities?company_group_id=&closed=false`, страницы дочитываются по `offset`, пока не получены
все `total`). Члены выбранной ГСК (`GET /directories/related-groups/{id}` → `members[]`) SHALL быть отмечены
автоматически; пользователь может снять или добавить отметки.
#### Scenario: Выбор ГСК отмечает её членов
- **WHEN** аналитик выбирает ГК «Русский бисквит» и её ГСК
- **THEN** в списке ЮЛ видны все ЮЛ ГК, а члены ГСК («АО "Русский бисквит"», «ООО "Пекарня Счастливочная"») отмечены
#### Scenario: ЮЛ больше одной страницы
- **WHEN** у ГК больше ЮЛ, чем `limit` одной страницы
- **THEN** модалка показывает все ЮЛ ГК, а не первую страницу
### Requirement: Создание и переход к версии
По «Создать» модалка SHALL отправить `POST /consolidations {company_group_id, related_group_id, year, quarter,
legal_entity_ids}` с отмеченными ЮЛ. При 201 модалка SHALL закрыться, список консолидаций ГК — перечитаться, а созданная
версия (`VersionCard.id`) — открыться в карточке. При ошибке модалка SHALL остаться открытой и показать `message` из
`ApiError` (коды `CONSOLIDATION_EXISTS`, `RELATED_GROUP_MISMATCH`, `ENTITY_NOT_IN_GROUP`, `PERIMETER_TOO_SMALL` и 422
валидации).
#### Scenario: Успешное создание
- **WHEN** аналитик создаёт консолидацию 2024 Q3 с периметром из ГСК
- **THEN** открыта карточка версии №1 со статусом «Инициация» и двумя ЮЛ в панели «Организации»
#### Scenario: Консолидация за период уже есть
- **WHEN** сервер отвечает 409 `CONSOLIDATION_EXISTS`
- **THEN** модалка не закрывается и показывает текст ошибки сервера
### Requirement: Переход к сводной ОСВ из карточки
Кнопка «К отчётности» карточки версии SHALL вести на `/groups/:group_id/versions/:version_id/osv` и быть доступна в
любом статусе версии.
#### Scenario: Переход к своду
- **WHEN** аналитик нажимает «К отчётности» у версии
- **THEN** открывается экран сводной ОСВ этой версии

View File

@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Экран документа
Маршрут `/documents/:document_id` SHALL загрузить `GET /documents/{id}` и показать вид по `document_type`: `BALANCE` и
`INCOME` — проверка формы, `OSV` — просмотр ОСВ; для прочих типов — карточку документа без данных. Шапка экрана SHALL
показывать тип, ЮЛ-владельца, период и статус документа, кнопку «Назад».
#### Scenario: Открытие формы баланса
- **WHEN** аналитик открывает документ `BALANCE`
- **THEN** показан экран проверки формы
### Requirement: Скан рядом с формой
Для ББ/ОФР экран SHALL показывать исходный файл документа: PDF — постранично через pdf.js из байт
`GET /files/{file_id}/content?disposition=inline`, полученных клиентом API с токеном (не через `<iframe>` и не прямой
ссылкой); изображение (png/jpg) — тегом `<img>` из blob; xlsx — без превью, с кнопкой «Скачать исходник».
#### Scenario: PDF под CSP sandbox
- **WHEN** сервер отдаёт PDF с `Content-Security-Policy: sandbox`
- **THEN** страницы PDF отрисованы на canvas и листаются
### Requirement: Форма ББ/ОФР — просмотр и правка
Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при
правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`. Ячейки с
`confidence < 100` или `is_recognized = false` SHALL подсвечиваться как «требует внимания», с `is_modified = true` — как
«исправлено» с исходным значением `original_amount` в подсказке. Единицы (`header.units`) SHALL выводиться над таблицей.
Пока документ не `CONFIRMED`, ячейка SHALL быть редактируемой: ввод проверяется по формату суммы (до 2 знаков после
точки, знак минус допустим, пустое — `null`), сохранение отправляет `PATCH /documents/{id}/statement {values:
[{line_id, column_id, amount}]}` суммой-строкой, ответ заменяет состояние экрана. Правка шапки (ИНН, дата отчёта,
единицы) SHALL отправляться тем же `PATCH` в `header`; `inn: null` не отправляется.
#### Scenario: Исправление ячейки
- **WHEN** аналитик меняет значение ячейки и сохраняет
- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят
#### Scenario: Неверный формат суммы
- **WHEN** аналитик вводит «12,3,4»
- **THEN** запрос не отправляется, поле показывает ошибку формата
### Requirement: Проверки и их акцепт
Экран SHALL показывать `checks`: жёсткие (`failed`), мягкие (`soft_failed`), межформенную (`cross_form`) и акцепт
(`accepted`: кто, когда, комментарий). При непустом `failed` и `accepted = null` SHALL быть доступно «Принять с
комментарием»: комментарий 1–2000 символов → `POST /documents/{id}/statement/checks/accept {comment}`.
#### Scenario: Акцепт нарушений
- **WHEN** есть жёсткие нарушения и аналитик принимает их с комментарием
- **THEN** блок проверок показывает акцепт, нарушения остаются видимыми
### Requirement: Подтверждение документа
Кнопка «Подтвердить» SHALL отправлять `POST /documents/{id}/confirm` и быть недоступна для `CONFIRMED` и `ERROR`. При 409
`CHECKS_NOT_PASSED` экран SHALL показать `detail.failures[]` и предложить акцепт; при 409 `PERIOD_REQUIRED` — подсказать
исправить дату отчёта; прочие ошибки — `message`. После успеха экран SHALL перейти в режим только чтения.
#### Scenario: Подтверждение с неакцептованными нарушениями
- **WHEN** аналитик подтверждает форму с жёсткими нарушениями без акцепта
- **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием»
### Requirement: ОСВ-документ
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) деревом по
`parent_entry_id` с колонками: счёт/субсчёт, контрагент, ИНН, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец
Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм.
#### Scenario: Подтверждение ОСВ
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
- **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION`

View File

@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Загрузка файлов ЮЛ в контексте версии
Панель «Файлы ЮЛ» [39]/[40], открытая из строки ЮЛ карточки версии, SHALL загружать выбранные файлы одним запросом
`POST /legal-entities/{id}/files?consolidation_version_id={version_id}` (multipart, поле `files` повторяется), без
других полей формы. Тип и период документа не спрашиваются. После ответа панель SHALL показать итог: принятые файлы
(`accepted[].name`) и отклонённые с причиной (`rejected[].name` + `message`). Ответ 422 с телом `UploadResult` SHALL
трактоваться как «ни один файл не принят» с тем же показом `rejected[]`; ответ 422 с `detail` (`ENTITY_NOT_IN_PERIMETER`)
и прочие ошибки — показом `message`.
#### Scenario: Часть файлов отклонена
- **WHEN** аналитик загружает ОСВ-файл и файл, уже загруженный ранее у этого ЮЛ
- **THEN** первый показан принятым, второй — отклонённым с текстом причины `DUPLICATE_FILE` от сервера
#### Scenario: Все файлы отклонены
- **WHEN** сервер отвечает 422 с телом `UploadResult`
- **THEN** панель показывает каждый отклонённый файл с причиной, а не общую ошибку
### Requirement: Список файлов и документов ЮЛ
Панель SHALL показывать файлы ЮЛ из `GET /legal-entities/{id}/files` (новые первыми): имя, статус файла по-русски
(«Обработка» для `UPLOADED`/`PROCESSING`, «Ошибка» с причиной из `job` для `ERROR`) и вложенные `documents[]` — тип
(«ОСВ <счёт>», «Бух. баланс», «ОФР», «Расшифровка», «Прочее»), период («2024 К1», «2024 · 9 мес.», «период не
определён»), статус документа («Требуется проверка», «Загружен» для `CONFIRMED`, «Ошибка»). Документы `OSV`, `BALANCE`,
`INCOME` SHALL быть ссылками на `/documents/:document_id`.
#### Scenario: PDF с двумя формами
- **WHEN** файл `aep_6.pdf` распознан
- **THEN** под файлом видны два документа — «Бух. баланс» и «ОФР» со статусом «Требуется проверка», каждый открывает
экран проверки
### Requirement: Опрос до окончания распознавания
Пока панель открыта и среди файлов есть `UPLOADED` или `PROCESSING`, панель SHALL перечитывать список раз в 5 секунд и
SHALL прекратить опрос, когда таких файлов нет или панель закрыта. При закрытии панели карточка версии SHALL
перечитываться (обновить ячейки чек-листа).
#### Scenario: Опрос останавливается
- **WHEN** все файлы ЮЛ перешли в `PROCESSED` или `ERROR`
- **THEN** запросы списка файлов прекращаются

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-09

View File

@ -0,0 +1,20 @@
## Context
Решения D-6 и R-7 взяты из design.md arm-core (change `statement-template-lines`, RSHB/arm-core#80).
### D-6. Фронт (arm-web, PR в `test`)
`statementGrid`: ключ строки — `line.id ?? "code-" + line_code` (R-10), ключ ячейки — `rowKey:column_id`; `draftToPatch`
отправляет `line_id` или `line_code` по ключу. Достроенная пустая ячейка (`id = null`) без подсветки «требует проверки»
(`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам —
меняется только то, что разделы теперь всегда полные. **Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются
цветом** (просьба владельца 09.10.2026): фон всей строки — светло-фиолетовый (`#f9f0ff`, purple-1 палитры antd): серый
`#f2f4f7` уже у заголовков разделов, светло-синий `#e6f4ff` — «исправлено», оранжевый `#fff7e6` — «требует проверки»;
шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки — сигнал о
данных важнее оформления. Признак — `formLayout` (`LayoutRow.grand: true` для 1600/1700 у `BALANCE`), цвет — `StatementTable`.
## Decisions made without discussion
| № | Дата | Решение | Обоснование | Что стоит ошибка |
|---|---|---|---|---|
| R-7 | 09.10.2026 | Цвет строк «БАЛАНС» — светло-фиолетовый `#f9f0ff` + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется | Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; фиолетовый не занят: серый — разделы, синий — «исправлено», оранжевый — «требует проверки» | Цвет не понравится — правка одной константы |
| R-10 | 09.10.2026 | Ключ достроенной строки `code-<code>`, а не `code:<code>` | ':' разделяет rowKey и columnId в cellKey | Нет |

View File

@ -0,0 +1,18 @@
## Why
RSHB/arm-core#80: сервер достраивает форму ББ/ОФР по справочнику редакции — строки и ячейки, которых не дало
распознавание, приходят с `id = null` (`origin = template`). Аналитик должен уметь заполнить их вручную. Кроме того,
итоговые строки «БАЛАНС» (1600 по активу, 1700 по пассиву) сливаются с обычными строками и «Итого по разделу».
## What Changes
- Строки и ячейки с `id = null` отображаются и редактируются как обычные.
- Правка достроенной строки уходит в `PATCH …/statement` по `line_code` (строки из БД — по `line_id`).
- Ключ строки на экране: `id`, а у достроенной — `code-<код>`.
- Строки 1600 и 1700 формы `BALANCE` выделяются фоном `#f9f0ff` и жирным шрифтом.
## Non-goals
- Порядок строк (раскладку по разделам делает `formLayout` как раньше).
- Правка кода и наименования строки.
- Выделение итогов ОФР.

View File

@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Форма ББ/ОФР — просмотр и правка
Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при
правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая
строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `confidence < 100` или
`is_recognized = false` SHALL подсвечиваться как «требует внимания», с `is_modified = true` — как «исправлено» с
исходным значением `original_amount` в подсказке; пустая достроенная ячейка не подсвечивается. Строки «БАЛАНС» формы
`BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом; подсветка ячейки перекрывает фон строки.
Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не `CONFIRMED`, ячейка SHALL быть редактируемой:
ввод проверяется по формату суммы (до 2 знаков после точки, знак минус допустим, пустое — `null`), сохранение
отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой: ячейка строки из БД — `{line_id,
column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code, column_id, amount}`; ответ заменяет
состояние экрана. Правка шапки (ИНН, дата отчёта, единицы) SHALL отправляться тем же `PATCH` в `header`;
`inn: null` не отправляется.
#### Scenario: Исправление ячейки
- **WHEN** аналитик меняет значение ячейки и сохраняет
- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят
#### Scenario: Заполнение строки, которой не было в распознавании
- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет
- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено»
#### Scenario: Строки «БАЛАНС»
- **WHEN** открыта форма `BALANCE`
- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет
#### Scenario: Неверный формат суммы
- **WHEN** аналитик вводит «12,3,4»
- **THEN** запрос не отправляется, поле показывает ошибку формата

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-09

View File

@ -0,0 +1,63 @@
## Context
Панель [39] (`widgets/FilesDrawer/FilesDrawer.tsx`) берёт `current_legal_entity_id` и `current_version_id` из стора,
грузит `GET /legal-entities/{id}/files?limit=100` хуком `useLegalEntityFiles` (опрос раз в 5 с, пока есть
`UPLOADED`/`PROCESSING`) и загружает файлы через `uploadFiles(legalEntityId, files, versionId)` с query
`consolidation_version_id`. Список консолидаций ГК (`IConsolidation[]` с `consolidation_id`, `year`, `quarter`,
`versions[] {id, status}`) уже лежит в сторе `consolidations`. Сквозной дизайн — `../docs/openspec/changes/console-files/design.md`.
## Decisions
**D-1. Консолидация и статус версии — селектор, не новое поле стора.** `store/slices/consolidations/selectors.ts`:
чистая функция `findCurrentConsolidation(consolidations, versionId) -> { consolidation_id, year, quarter, version_status }
| null` (ищет версию по `id` в `versions[]`) и хук-обёртка `useCurrentConsolidation()` через `useAppSelector`. Поле
`current_consolidation_id`, упомянутое в сквозном дизайне, не вводится: оно выводится из уже хранимого и не может
рассинхронизироваться с `?version=` (R-1).
**D-2. Один сервис, два маршрута.** `listLegalEntityFiles(legalEntityId, consolidationId: string | null)` и
`uploadFiles(legalEntityId, files, consolidationId: string | null)`: при `consolidationId` путь
`/consolidations/{cid}/legal-entities/{le}/files`, иначе `/legal-entities/{le}/files`. Обработка 422 `UploadResult`
без изменений.
**D-3. Режим панели — локальное состояние `showAll`.** `useLegalEntityFiles(legalEntityId, consolidationId, open)`:
смена `consolidationId` сбрасывает список и перечитывает (как смена ЮЛ). В панели переключатель antd `Switch` с
подписью «Все документы организации» под кнопкой загрузки; `consolidationId = showAll ? null : current.consolidation_id`.
Если консолидация не найдена (версия не из списка) — панель работает в режиме «все» и переключатель скрыт.
**D-4. Подпись консолидации у файла.** `widgets/FilesDrawer/consolidationLabel.ts`:
`consolidationLabel(fileConsolidationId, consolidations, currentConsolidationId) -> string | null`: `null`, если файл в
текущей консоли; «вне консолидации» при `null`; «2025 К1» (`year` + `quarterShortLabel`) по списку; «другая
консолидация», если id нет в списке. `buildDocumentRows(files, labelFor?)` добавляет результат в `subtitle` последним
элементом; `FilesList` получает `labelFor` от панели только в режиме «все».
**D-5. Загрузка только в версии «в работе».** `IN_WORK_STATUSES = ['INITIATED', 'DOCUMENTS_PROCESSING', 'DATA_CORRECTION']`
в `selectors.ts`; кнопка «Загрузить документы» `disabled`, если `version_status` не в списке, с `Tooltip` «Загрузка
доступна только в версии в работе». Модалка получает `consolidationId` вместо `versionId`.
**D-6. Опрос** без изменений: тот же хук, тот же интервал; перечитывается активный список.
## Risks / Trade-offs
- До выката arm-core#96 на стенд панель получит 404 от маршрутов консолидации — ветка вливается после ядра.
- `limit=100` остаётся: стол консоли меньше списка ЮЛ, ограничение стало мягче.
## Decisions made without discussion
1. 09.10.2026 — **R-1:** консолидация и статус версии выводятся селектором из `consolidations[]` + `current_version_id`,
а не хранятся отдельным полем `current_consolidation_id` (сквозной дизайн docs #90 называл поле). Цена ошибки:
низкая — если список ГК в сторе пуст (прямой переход по ссылке до загрузки списка), панель секунду работает в
режиме «все»; список грузится той же страницей.
2. 09.10.2026 — **R-2:** подпись консолидации показывается только в режиме «Все документы организации» и только у файлов
не из текущей консоли; в режиме стола подписи нет (все файлы — этой консоли или подхваченные). Цена ошибки: низкая.
3. 09.10.2026 — **R-3:** неизвестный `consolidation_id` (нет в списке ГК) подписывается «другая консолидация», а не
скрывается. Цена ошибки: нулевая.
4. 10.10.2026 — **R-4:** ручной прогон на стенде отложен до выката arm-core#96. Цена ошибки: UI-дефект всплывёт позже;
PR не вливать до прогона.
5. 10.10.2026 — **R-5:** trailer коммита 5b7b02a «Claude Haiku 5.5» вместо «Claude Fable 5.1» оставлен. Цена ошибки:
неверная атрибуция в одном коммите.
6. 10.10.2026 — **R-6:** промежуточное состояние (tsc-ошибка и versionId в UploadDocumentsModal между Task 2 и Task 3)
принято, чинится в Task 3. Цена ошибки: нулевая, закрыто в Task 3.
7. 10.10.2026 — **R-7:** `consolidation_id` в scripts/demo-prepare.sh берётся из карточки версии
`GET /consolidations/versions/$V`, а не из `POST /consolidations`. Цена ошибки: скрипт демо не запускался на живом ядре.
8. 10.10.2026 — **R-8:** после создания консолидации перечитывается список консолидаций группы (I-1). Цена ошибки:
правка выходит за Impact proposal, лишний запрос.

View File

@ -0,0 +1,51 @@
## Why
Панель «Первичные документы» [39], открытая из строки ЮЛ в карточке версии, показывает все файлы ЮЛ
(`GET /legal-entities/{id}/files`), в том числе загруженные в другие консолидации ГК. Решения владельца 09.10.2026
(RSHB/arm-ka-docs#90, сквозной change `../docs/openspec/changes/console-files/`; ядро — RSHB/arm-core#96): файл
загружается в консолидацию (ГК + период) и образует её «рабочий стол»; ядро даёт маршруты
`POST/GET /consolidations/{cid}/legal-entities/{le}/files`, а query `consolidation_version_id` у
`POST /legal-entities/{id}/files` удаляет. Issue — RSHB/arm-web#26.
## What Changes
Экраны: [39] панель «Первичные документы», [40] модалка «Загрузка документов». Эндпоинты arm-core:
`GET /consolidations/{cid}/legal-entities/{le}/files` (новый), `POST /consolidations/{cid}/legal-entities/{le}/files`
(новый), `GET /legal-entities/{id}/files` (без изменений, в `FileOut` добавлено `consolidation_id`),
`POST /legal-entities/{id}/files` (без query версии).
- Консолидация и статус открытой версии выводятся из стора (`consolidations[]` + `current_version_id`) селектором;
отдельного поля в сторе нет.
- `services/api/files.ts`: `listLegalEntityFiles(legalEntityId, consolidationId)` и
`uploadFiles(legalEntityId, files, consolidationId)` — с консолидацией маршруты `/consolidations/{cid}/…`, без неё
маршруты ЮЛ; query `consolidation_version_id` исчезает.
- Панель [39]: по умолчанию рабочий стол консолидации; переключатель «Все документы организации» переключает на список
ЮЛ; в режиме «все» у файла в подписи — его консолидация («2025 К1» по списку консолидаций ГК, «вне консолидации» при
`null`, «другая консолидация» при неизвестном id). Опрос раз в 5 с — по активному списку.
- Кнопка «Загрузить документы» недоступна, если открытая версия не «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`,
`DATA_CORRECTION`), с подсказкой.
- Типы: `FileOut.consolidation_id: string | null`.
- Спека `primary-files-ui`: три требования переписаны/добавлены.
## Non-goals
- Экран [60] «Монозаёмщики» и панель без контекста консолидации — не делаются (их пока нет во фронте).
- Пометка «документ не подходит версии» в панели — нет (чек-лист версии, OQ-120).
- Перенос файла между консолидациями, удаление файла из панели — нет (как и сейчас).
- Изменение экрана проверки документа [41] и сводной ОСВ — нет.
- `arm-core/web` (дубль фронта в ядре) не трогается.
## Capabilities
### New Capabilities
### Modified Capabilities
- `primary-files-ui`: загрузка в консолидацию, рабочий стол и переключатель «Все документы организации», недоступная
загрузка вне версии «в работе».
## Impact
- `src/services/api/files.ts` (+ `files.test.ts`), `src/widgets/FilesDrawer/{FilesDrawer,FilesList,UploadDocumentsModal}.tsx`,
`src/widgets/FilesDrawer/{useLegalEntityFiles,documentRows}.ts` (+ тест), новый `src/widgets/FilesDrawer/consolidationLabel.ts`
(+ тест), новый `src/store/slices/consolidations/selectors.ts` (+ тест), `src/types/api.ts`.
- Зависимость: ядро с RSHB/arm-core#96 на стенде; до его выката панель получит 404 на маршрутах консолидации.

View File

@ -0,0 +1,62 @@
## MODIFIED Requirements
### Requirement: Загрузка файлов ЮЛ в контексте версии
Панель «Файлы ЮЛ» [39]/[40], открытая из строки ЮЛ карточки версии, SHALL загружать выбранные файлы одним запросом
`POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (multipart, поле `files` повторяется),
где `consolidation_id` — консолидация открытой версии (из списка консолидаций ГК в сторе по `current_version_id`),
без других полей формы и без query `consolidation_version_id`. Тип и период документа не спрашиваются. Кнопка
«Загрузить документы» SHALL быть недоступна с подсказкой «Загрузка доступна только в версии в работе», если статус
открытой версии не `INITIATED`, `DOCUMENTS_PROCESSING` или `DATA_CORRECTION`. После ответа панель SHALL показать итог:
принятые файлы (`accepted[].name`) и отклонённые с причиной (`rejected[].name` + `message`). Ответ 422 с телом
`UploadResult` SHALL трактоваться как «ни один файл не принят» с тем же показом `rejected[]`; ответ 422 с `detail`
(`ENTITY_NOT_IN_PERIMETER`), 409 (`CONSOLIDATION_NOT_EDITABLE`) и прочие ошибки — показом `message`.
#### Scenario: Часть файлов отклонена
- **WHEN** аналитик загружает ОСВ-файл и файл, уже загруженный ранее у этого ЮЛ
- **THEN** первый показан принятым, второй — отклонённым с текстом причины `DUPLICATE_FILE` от сервера
#### Scenario: Все файлы отклонены
- **WHEN** сервер отвечает 422 с телом `UploadResult`
- **THEN** панель показывает каждый отклонённый файл с причиной, а не общую ошибку
#### Scenario: Загрузка идёт в консолидацию
- **WHEN** панель открыта из версии консолидации 2024 Q3 и аналитик загружает файл
- **THEN** запрос уходит на `/consolidations/{id консолидации 2024 Q3}/legal-entities/{id ЮЛ}/files` без query; после
приёма файл появляется в столе панели
#### Scenario: Версия не в работе
- **WHEN** панель открыта из версии в статусе `CANCELLED`
- **THEN** кнопка «Загрузить документы» недоступна, при наведении — подсказка
### Requirement: Список файлов и документов ЮЛ
Панель SHALL по умолчанию показывать рабочий стол консолидации открытой версии —
`GET /consolidations/{consolidation_id}/legal-entities/{id}/files` (новые первыми): имя, статус файла по-русски
(«Обработка» для `UPLOADED`/`PROCESSING`, «Ошибка» с причиной из `job` для `ERROR`) и вложенные `documents[]` — тип
(«ОСВ <счёт>», «Бух. баланс», «ОФР», «Расшифровка», «Прочее»), период («2024 К1», «2024 · 9 мес.», «период не
определён»), статус документа («Требуется проверка», «Загружен» для `CONFIRMED`, «Ошибка»). Документы `OSV`, `BALANCE`,
`INCOME` SHALL быть ссылками на `/documents/:document_id`. Переключатель «Все документы организации» SHALL переключать
панель на `GET /legal-entities/{id}/files`; в этом режиме у файла не из текущей консолидации в подписи SHALL стоять его
консолидация: «<год> К<n>» по списку консолидаций ГК, «вне консолидации» при `consolidation_id = null`, «другая
консолидация», если id нет в списке. Если консолидация открытой версии не определена (версии нет в списке ГК), панель
SHALL работать в режиме «все» без переключателя.
#### Scenario: PDF с двумя формами
- **WHEN** файл `aep_6.pdf` распознан
- **THEN** под файлом видны два документа — «Бух. баланс» и «ОФР» со статусом «Требуется проверка», каждый открывает
экран проверки
#### Scenario: Стол и все документы
- **WHEN** у ЮЛ есть файлы, загруженные в консолидации 2024 Q3 и 2024 Q4, и файл, загруженный без контекста; панель
открыта из версии 2024 Q3
- **THEN** по умолчанию виден только файл 2024 Q3; после переключателя «Все документы организации» видны все три, у
файла 2024 Q4 подпись «2024 К4», у файла без контекста — «вне консолидации», у файла 2024 Q3 подписи нет
### Requirement: Опрос до окончания распознавания
Пока панель открыта и среди файлов активного списка (стол или все документы) есть `UPLOADED` или `PROCESSING`, панель
SHALL перечитывать этот список раз в 5 секунд и SHALL прекратить опрос, когда таких файлов нет или панель закрыта.
Переключение режима или смена ЮЛ SHALL сбрасывать список и перечитывать его. При закрытии панели карточка версии SHALL
перечитываться (обновить ячейки чек-листа).
#### Scenario: Опрос останавливается
- **WHEN** все файлы активного списка перешли в `PROCESSED` или `ERROR`
- **THEN** запросы списка файлов прекращаются

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-18

View File

@ -0,0 +1,90 @@
## Context
Реализация плана 6 сквозного change `unified-statement-normalization`
(`../docs/openspec/changes/unified-statement-normalization/`, спека `specs/arm-web/spec.md`). Вся логика экрана —
чистые функции в `.ts` с vitest-тестами, компоненты React только рисуют их результат. Черновик правок аналитика —
одна структура `StatementDraft`, из которой одна функция собирает тело единственного `PATCH`. Подробный план —
`docs/superpowers/plans/2026-10-18-statements-v2.md`.
## Decisions made without discussion
Дата: 2026-10-18. Решения плана, принятые без обсуждения с владельцем; если решение неверно, цена ошибки — в
последнем столбце.
| Маркер | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| P6-1 | Подсказка кода — `StatementOut.edition_lines` (справочник строк редакции формы, у формы без даты — всех редакций; план 3 P3-22), затем коды строк `ok` файла, которых в справочнике нет (имя — `reference_name` ?? `line_name`); отдельного запроса нет | справочник приходит с ответом формы | нет |
| P6-2 | Якорь строки `unreadable` / `missing` — строка `ok` из источника (`row_index` не `null`) с наибольшим `row_index` меньше своего; якорь `detail` — строка `ok` из источника с `row_index = parent_row_index`; нет якоря (в том числе `detail` с `parent_row_index = null` или указывающим не на `ok`) — в начало первого раздела (после ведущих заголовков разделов; у ОФР — в начало таблицы). Вложение встаёт сразу после строки-якоря; несколько вложений одного якоря — по `row_index` | спека `arm-web` «Раскладка», S-62 («строка `detail` без родителя на экране — в начале раздела»), P3-17 (тот же порядок вложений в ответе ядра) | нет |
| P6-3 | Скрытие (пустые подстроки, расшифровки) применяется **после** раскладки, фильтром | иначе скрытый якорь сдвинул бы вложения к соседу | нет |
| P6-4 | Справочное имя (`reference_name`) — основное; файловое (`line_name`, после срезания приклеенных заголовков разделов) показывается вторым серым текстом, только если отличается после нормализации: нижний регистр, `ё` → `е`, оставлены только буквы и цифры | переносы OCR («задолжен-ность») не должны давать ложного «отличается» | лишнее или пропущенное второе имя — только показ |
| P6-5 | Маркеры «у места» строятся из **всех** записей `issues` (в том числе `relation` / `soft` — у них `line_ids` + `column_id`); список `IssuesPanel` показывает только `kind = warning`; нарушения §7 остаются списком в `ChecksPanel` (из `checks`, с акцептом) и получают ссылку «Показать» по `line_ids[0]`. Запись с `column_id` без `line_ids` (например, `COLUMN_WITHOUT_DATE`) показывается у заголовка колонки; поле шапки, которого нет в блоке шапки, и строки, которых нет в ответе, — у документа | одна запись не дублируется в двух списках; заголовок колонки — естественное место для предупреждения о дате колонки | нет |
| P6-6 | Подтверждение ячейки — в черновике; доступно, только если у строки есть `id` и у значения есть `id` (у достроенных ячеек — нет, ядро ответит `UNKNOWN_CELL`); сумма в черновике той же ячейки отменяет её подтверждение | `PATCH`: в элементе `values` ровно одно из `amount` / `confirmed`; правка суммы и так снимает `needs_review` | нет |
| P6-7 | Одно действие на строку в черновике (`code` / `detail` / `confirm`), новое заменяет прежнее; пустое поле кода и код, равный текущему, — не действие; у достроенных шаблонных строк (`id = null`) действий строки нет | 422 `INVALID_LINE_PATCH` при двух действиях; `lines[]` адресуется только `line_id` | нет |
| P6-8 | Сумма в шаблонную строку по `line_code` X и назначение кода X другой строке в одном сохранении — ошибка черновика «Код X назначается строке, а в шаблонную строку X введены суммы — сохраните по очереди», запрос не уходит | ядро ответит 422 `UNKNOWN_CELL` (P3-10), а сообщение ядра не говорит, что делать | нет |
| P6-9 | Ошибка элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `LINE_CODE_REQUIRED`) показывается у поля кода строки `detail.line_id` (текст сервера в подсказке поля и `message.error`); строки с этим `id` на экране нет — текстом над таблицей | ядро отдаёт `detail.line_id` и `detail.line_code` (план 3 P3-24) | нет |
| P6-10 | Строка «неразобранная» для кнопки «Подтвердить данные» — `line_status ∈ {unreadable, missing}` и хотя бы у одного значения `amount !== null` или `string_value !== null` (ровно условие ядра для 409 `LINES_UNRESOLVED`) | кнопка и сервер считают одинаково | нет |
| P6-11 | Подтверждение даты отчёта — кнопка «Подтвердить дату» у поля из `needs_review_fields` (уходит `header.report_date` тем же значением); дата колонки — поле `ГГГГ-ММ-ДД` у заголовка колонки (как поле даты отчёта) с кнопкой «Подтвердить дату», если дата есть и у колонки `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` | перепечатывать ту же дату ради подтверждения неочевидно; формат ввода — как у существующего поля даты | нет |
| P6-12 | Панель файлов: у строки документа — значок с числом «предупреждения файла (`job.warnings`) + `issue_counts.error` + `issue_counts.info`» документа, красный при хотя бы одном `error` (в файле или `issue_counts.error > 0`); у строки файла без документов — только `job.warnings`; нуль — значка нет. Клик открывает список: предупреждения файла (`message` и `locator`), `job.mapping_warnings` (как `info`) и `DocumentOut.issues` документа, загружаемые при открытии (`GET /documents/{id}`) | `DocumentBrief.issue_counts` (план 3 P3-23) помечает документ без запроса на каждый; подробности — по клику | акцептованное нарушение тоже даёт значок — подробности в списке |
| P6-13 | `job.warnings` на клиенте нормализуется так же, как ядро: строка → `{code: "LEGACY_WARNING", severity: "info", message, locator: null}`, объект с `code`, `severity ∈ {error, info}`, `message` — как есть, прочее пропускается, не список → `[]` | экран не падает при ошибке порядка выкладки | нет |
| P6-14 | Сценарий T11 — на локальном стенде; строки `unreadable` / `missing` / `detail` и записи `issues` всех видов подставляются перехватом `GET` и `PATCH …/statement` (`page.route`) из фикстуры плана, пока сервисы шлют 1.x; на реальных формах 1.x — регрессия | до шага 5 выкладки 2.0-данных на стенде нет | повторный прогон на `aep_3` после шага 5 — follow-up |
| P6-15 | Цвета: ячейка `needs_review` — `#fff7e6`, исправлено — `#e6f4ff`, строка `unreadable` / `missing` целиком — `#fff1f0`, строка `ok` с `needs_review` — фон ячеек наименования и кода `#fff7e6`; подтверждённая ячейка — маленькая серая галочка перед суммой; уровень `error` — красный значок `ExclamationCircleOutlined` `#cf1322`, `info` — синий `InfoCircleOutlined` `#1677ff` | палитра уже используется на экране и в панели файлов | только вид |
| P6-16 | Ячейка кода: `ok` — код; `unreadable` — серым `line_code_text` (пусто — «—»), подсказка «Код не распознан: «<текст>»»; `missing` — серым «—», подсказка «Кода в строке нет»; `detail` — пусто, подсказка «Расшифровка — в суммы формы не входит»; у `ok` в подсказке «Уверенность кода: N%» (если меньше 100 и код не правлен), «Код подтверждён», «Код назначен аналитиком» | спека: «в ячейке кода серым — сырой текст с подсказкой» | только вид |
| P6-17 | `header.source.<поле>.confidence` — `string \| null` (строка Decimal, как прочие уверенности API, план 3 P3-20), в подсказке — `Number()` и округление до целого | одно правило для всех уверенностей | нет |
| P6-18 | `node_modules` worktree — симлинк на установленный каталог с eslint, prettier и vitest 3.1.4; поэтому вместо `make check` (он начинается с `npm install` и поставил бы пакеты в чужой каталог) гоняется `make lint typecheck test build` — те же проверки без установки | память владельца: симлинк, как в прежних срезах | нет; в описании PR сказано, что install пропущен |
| P6-19 | Предупреждения ОСВ-документа (`DocumentOut.issues`, `OSV_*`) — блок `IssuesPanel` над таблицей экрана ОСВ, все записи — у документа (строки ОСВ не адресуются `line_ids`) | спека: «предупреждения листа ОСВ … из `DocumentOut.issues[]`»; экран документа уже загружает `DocumentOut` | нет |
| R-1 | Шаги «сверка с макетом / прогон в браузере» (T3 s5, T4 s4, T7–T10) перенесены в T11 (дата 2026-10-18) | нет стенда ядра с v5.19, общий Playwright-браузер делят параллельные дорожки | визуальные дефекты найдутся позже, в T11 |
| R-2 | Ссылка «Показать» также у проверок со статусом `soft_failed` (дата 2026-10-18) | согласованное расширение: нарушение так же адресуется строкой | только показ |
| R-3 | Компонент меню строки назван `LineMenuButton.tsx`, а не `LineMenu.tsx` (дата 2026-10-18) | на регистронезависимой ФС (macOS) `LineMenu.tsx` и `lineMenu.ts` дают TS1149 | нет |
| O-1 | Два MODIFIED-требования («Форма ББ/ОФР — просмотр и правка», «Подтверждение документа») составлены исполнителем из текущей спеки: в docs-спеке `arm-web` все требования ADDED. «Предупреждения ОСВ-документа» (P6-19) в docs-спеке нет; написано по плану. Сверить в arm-ka-docs#116 (дата 2026-10-18) | иначе change нельзя было бы описать дельтой | расхождение формулировок со спекой docs при архивации |
| T1.D-1 | `cellFlags` возвращает ещё и `confirmed`; двум старым ожиданиям добавлен `confirmed: false`; тест «внимание по уверенности» заменён на «внимание только по needs_review» (дата 2026-10-18) | `toEqual` падает на новом ключе; так велит бриф | только тесты |
| T1.D-2 | В тестовый хелпер `doc()` добавлен `issue_counts: {error: 0, info: 0}` (дата 2026-10-18) | поле обязательно в `DocumentBrief`, иначе падает tsc | нет |
| T1.D-3 | Значения фикстуры `statement-v2.json`, которых нет в брифе, придуманы исполнителем (форматы сумм, уверенности, `text`, UUID, `codes`, сообщения, `is_current`, пустая колонка c2, порядок `edition_lines`) (дата 2026-10-18) | фикстура должна быть полной | тесты завязаны на выдуманные значения; реальные данные — в T11 |
| T1.D-4 | Фикстура приведена к `StatementOut` через `as` в тесте (дата 2026-10-18) | импорт JSON расширяет литералы до `string`; полноту проверяет рантайм-тест пар «строка × колонка» | слабая проверка типов фикстуры |
| T1.D-5 | Тест «переносит поля v2» проверяет ключи по `l-u7`, `code-1250`, `l-1230`, `l-d2` (дата 2026-10-18) | ровно как в брифе | нет |
| T3.D-1 | Несколько строк подсказки кода склеиваются переводом строки; у `codeModified` уверенность не показывается (дата 2026-10-18) | читаемость подсказки | только вид |
| T3.D-2 | Подсказка сумм принимает необязательный `issues` (добавляет message) (дата 2026-10-18) | задел для T4 | нет |
| T3.D-3 | Уверенность округляется `Math.round(Number())` (P6-17) (дата 2026-10-18) | одно правило для всех уверенностей | нет |
| T3.D-4 | В режиме правки у подтверждённой ячейки галочка — `prefix` поля ввода (дата 2026-10-18) | ячейка в режиме правки — Input | наложение значков, см. T8.D-3 |
| T3.D-5 | `id` строки `row-<key>` выставляется только на строках-линиях, не на заголовках разделов (дата 2026-10-18) | якорь прокрутки нужен только строкам | нет |
| T4.D-1 | Подписи мест полей шапки: organization_name — «Организация», inn — «ИНН», report_date — «Дата отчёта», units — «Единица измерения»; неизвестное поле — своим кодом, запись уходит в `document` (дата 2026-10-18) | P6-5 | только вид |
| T4.D-2 | Запись с `field` из блока шапки всегда в `byField`, независимо от `line_ids` (дата 2026-10-18) | поле шапки — самостоятельное место | нет |
| T4.D-3 | `warning` с `line_ids` без `column_id` — место «строка …», с `column_id` — «<строка>, колонка «…»»; `relation` / `soft` в список не входят (дата 2026-10-18) | P6-5 | только вид |
| T4.D-5 | `onShow` панелей передаёт ключ строки; для достроенных строк (`id = null`) перехода нет — `line_ids` их не содержат (дата 2026-10-18) | нет id — нет адреса | нет ссылки «Показать» у достроенной строки |
| T4.D-6 | Значок у наименования строки — только по `byRow`; записи `byCell` дают значок только в ячейке (дата 2026-10-18) | не дублировать | только вид |
| T5.D-1 | Кнопка «Подтвердить дату» рисуется только при переданном `onConfirmDate` (дата 2026-10-18) | иначе мёртвая кнопка | нет |
| T5.D-2 | У организации в подсказке при modified — «Распознано: «…»»; нет `source` — подсказки нет (дата 2026-10-18) | нечего показывать | нет |
| T5.D-3 | Уверенность в подсказке шапки через `Number()` ('67.00' → 67%) (дата 2026-10-18) | P6-17 | нет |
| T5.D-4 | Placeholder организации — «Наименование организации» (текст не из плана) (дата 2026-10-18) | поле в режиме правки должно быть подписано | только вид |
| T6.D-1 | `buildStatementPatch` для суммы в неизвестную строку возвращает ошибку `amount` (раньше бросалось исключение) (дата 2026-10-18) | экран не должен падать | нет |
| T6.D-2 | Подтверждение ячейки и действие строки у строк без `id` молча пропускаются (дата 2026-10-18) | UI их не создаёт (P6-6, P6-7) | молчаливая потеря действия при ошибке UI |
| T6.D-3 | Код сравнивается после `trim`; пустой или равный текущему — не действие и не ошибка, проверка до формата (дата 2026-10-18) | P6-7 | нет |
| T6.D-4 | Конфликт P6-8 ищется по сумме в шаблонную строку (`id = null`) с тем же кодом; `key` ошибки — `lineId` строки с действием (дата 2026-10-18) | P6-8 | нет |
| T6.D-5 | Тексты ошибок: «Наименование организации — не длиннее 1000 символов» (длина организации); «Исправьте суммы с неверным форматом» (суммы, как раньше) (дата 2026-10-18) | тексты не из плана | только вид |
| T6.D-6 | `confirm_report_date` при отсутствии текущей даты ничего не отправляет (дата 2026-10-18) | нечего подтверждать | пустое тело PATCH, «Сохранить» молчит |
| T6.D-7 | Ошибка `amount` подсвечивает ячейку красной рамкой и сама по себе (дата 2026-10-18) | раньше подсветка шла только по разбору формата | только вид |
| T7.D-2 | Подпись пункта меню: «Это расшифровка» для `unreadable` / `missing`, «Сделать расшифровкой» для `ok` (дата 2026-10-18) | разные смыслы действия | только вид |
| T7.D-3 | Меню строки у `ok` — только при `origin = 'source'`; шаблонные строки с id меню не получают (дата 2026-10-18) | бриф | нет |
| T7.D-4 | `lineErrorTarget` читает `ApiError.body.detail` (отдельного поля `detail` у `ApiError` нет) (дата 2026-10-18) | иначе деталей ошибки не достать | нет |
| T7.D-5 | Ошибка сервера сбрасывается любой правкой черновика (в `setDraft`) (дата 2026-10-18) | иначе устаревшая ошибка висит после правки | нет |
| T7.D-6 | У `unreadable` / `missing` с действием detail / confirm поле кода остаётся, рядом тег (дата 2026-10-18) | бриф: поле всегда | см. T7.2: очистка поля теряет действие |
| T8.D-1 | Подтверждение ячейки без `id` значения молча пропускается в `buildStatementPatch`; кнопка не рисуется (`cellConfirmState → null`) (дата 2026-10-18) | P6-6 | нет |
| T8.D-2 | Ожидающая кнопка даты — disabled с текстом «Дата подтверждена (до сохранения)»; без ожидания «Подтвердить дату»; отменить подтверждение даты можно только отменой всей правки. Для колонки ожидание = черновая дата равна `period_date`. Затем текст подсказки при нажатой кнопке подтверждения ячейки: «Подтверждение будет сохранено — нажмите, чтобы убрать» (дата 2026-10-18) | пользователь должен видеть, что подтверждение в черновике | текст не из плана; ширина колонки (T8.2) проверяется в T11 |
| T8.D-3 | При значке записи у Input `paddingLeft: 14`, чтобы значок не перекрывал галочку (дата 2026-10-18) | без браузера не проверено | визуальное наложение; проверка в T11 |
| T8.D-4 | `cellConfirmState` возвращает `pending-unconfirm` при `confirms[key] === false` (дата 2026-10-18) | нужно третье состояние | нет |
| T8.D-5 | Подтверждение ячейки только строкам с `lineId` (дата 2026-10-18) | P6-6, P6-7 | нет |
| T9.D-1 | «Значения есть» = `amount !== null || string_value !== null` по любой ячейке; `detail` не блокирует независимо от значений (дата 2026-10-18) | P6-10 | расхождение с условием ядра |
| T9.D-2 | Ключ ссылки в 409 `LINES_UNRESOLVED` — `line_id` (дата 2026-10-18) | ключ GridRow для строк из БД | ссылка молчит, если строки нет в таблице (T9.6) |
| T9.D-3 | Описание Alert подтверждения собирается императивно (`confirmDescription`); приоритет: failures > lines > duplicate (дата 2026-10-18) | линтер запрещает вложенные тернарники | только вид |
| T10.D-1 | `toFileWarnings` отбрасывает объекты без `code` / `message` или с `severity` не `error` / `info` (дата 2026-10-18) | P6-13 | нет |
| T10.D-2 | В режиме `document` у `IssuesPanel` колонка «место» не показывается; `OsvDocument` кладёт place '' / targetKey null (дата 2026-10-18) | у документа нет места | нет |
| T10.D-3 | Подробности документа в поповере грузятся при открытии (`trigger=click`), состояние сбрасывается при закрытии (дата 2026-10-18) | P6-12 | лишний запрос при повторном открытии |
| T10.D-4 | Значок — `<button>` под плашкой статуса; без `documentId` раздел «Предупреждения документа» не показывается (дата 2026-10-18) | правила a11y | нет |
| T10.D-5 | Пустой список в разделе «Предупреждения документа» — «Нет» (дата 2026-10-18) | текст не из плана | только вид |
| UI-1 | Тексты, которых нет в плане: «Дата подтверждена (до сохранения)», «Подтверждение будет сохранено — нажмите, чтобы убрать», «Строка не найдена в форме», «Наименование организации — не длиннее 1000 символов» (дата 2026-10-18) | нужны для состояний, плана для них не было | только формулировки |
| T11.D-1 | Колонки значений 128px вместо 112 (поле даты из 10 символов и сумма с галочкой обрезались), `tableLayout='fixed'` (дата 2026-10-18) | замечание T11.1: колонка «прыгала» при появлении кнопки даты | только вид |
| T11.D-2 | Кнопка подтверждения даты — короткая «Подтвердить» / «Подтверждено», длинный текст «Подтвердить дату» / «Дата подтверждена (до сохранения)» в подсказке и `aria-label` (дата 2026-10-18) | фиксированная ширина колонки; отклонение от формулировки «Подтвердить дату» в P6-11 | только подпись |
| T11.D-3 | Ссылка «Разберите строки без кода: N» — `Button type=link` отдельной строкой над шапкой карточки, видна и в режиме правки (дата 2026-10-18) | клавиатурный доступ; строки нужно разбирать именно при правке | только вид |
| T11.D-4 | Список предупреждений документа показывает первые 3 записи (ошибки первыми) и «Показать все (N)»; то же на экране ОСВ (дата 2026-10-18) | таблица уезжала ниже сгиба | только вид |
| T11.D-5 | Значение со `string_value` без суммы показывается серым текстом, а не «-» (дата 2026-10-18) | аналитик должен видеть нераспознанный текст | только вид |
Решения контроллера и исполнителей поверх плана (`R-n`, `O-n`) дописываются сюда по ходу исполнения.

View File

@ -0,0 +1,34 @@
## Why
Сквозной change `unified-statement-normalization` (RSHB/arm-ka-docs#116) переводит ББ/ОФР на единую нормализацию:
ядро (API v5.19, контракт 2.0) отдаёт все строки источника со статусами `ok` / `unreadable` / `missing` / `detail`,
признак `needs_review` у ячеек, строк и полей шапки, предупреждения `document_issue` и структурные `job.warnings`.
Экран формы и карточка файла должны это показывать и давать разобрать. Issue — RSHB/arm-web#43. План —
`docs/superpowers/plans/2026-10-18-statements-v2.md` (план 6 сквозного change).
## What Changes
Экраны: [41] проверка формы ББ/ОФР, [39] панель файлов (карточка файла).
- Раскладка «как на бумаге»: строки `unreadable` / `missing` под ближайшей предыдущей строкой с кодом, `detail` под
переключателем «Показать расшифровки (N)», справочное имя строки основное, файловое — рядом, если отличается.
- Подсветка только по `needs_review` (ячейки, строки, поля шапки); предупреждения и нарушения у места
(`issues`, `checks`).
- Режим «Редактировать»: назначение кода, «Это расшифровка», «Сделать расшифровкой», подтверждение ячеек, кодов и дат,
правка наименования организации, один `PATCH …/statement` (`header`, `values`, `columns`, `lines`).
- «Подтвердить данные» отключена при неразобранных строках; разбор 409 `LINES_UNRESOLVED`, `DUPLICATE_FORM`,
`LINE_CODE_TAKEN`.
- Карточка файла читает `job.warnings` структурами `{code, severity, message, locator}`; предупреждения ОСВ-документа
и документа — из `DocumentOut.issues`.
- Спеки: `document-review-ui` (2 MODIFIED, 4 ADDED), `primary-files-ui` (1 ADDED).
## Non-goals
- Переключатель «как в файле» (Р-14: раскладка одна).
- Склейка форм с разных листов/файлов.
- Изменения API ядра и спеки docs: расхождения фиксируются как `O-n` в отчётах задач.
## Dependencies
Выкладка вместе с ядром (план 3 сквозного change, API v5.19): `JobBrief.warnings` меняет тип со строк на структуры.
До выкладки 2.0-данных на стенде строки со статусами подставляются перехватом запросов (P6-14).

View File

@ -0,0 +1,109 @@
## MODIFIED Requirements
### Requirement: Форма ББ/ОФР — просмотр и правка
Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при
правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая
строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `needs_review = true` SHALL
подсвечиваться как «требует внимания» (подсветки напрямую по `confidence < 100` или `is_recognized = false` нет), с
`is_modified = true` — как «исправлено» с исходным значением `original_amount` в подсказке; пустая достроенная ячейка не
подсвечивается. Строки «БАЛАНС» формы `BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом;
подсветка ячейки перекрывает фон строки. Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не
`CONFIRMED`, ячейка SHALL быть редактируемой: ввод проверяется по формату суммы (до 2 знаков после точки, знак минус
допустим, пустое — `null`), сохранение отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой:
ячейка строки из БД — `{line_id, column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code,
column_id, amount}`; ответ заменяет состояние экрана. Правка шапки (ИНН, дата отчёта, единицы, наименование
организации) SHALL отправляться тем же `PATCH` в `header`; `inn: null` не отправляется. Правка строк, колонок и
подтверждений в том же `PATCH` (`columns`, `lines`, `values[].confirmed`) описана в требовании «Правка строк, колонок и
подтверждение в режиме «Редактировать»».
#### Scenario: Исправление ячейки
- **WHEN** аналитик меняет значение ячейки и сохраняет
- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят
#### Scenario: Заполнение строки, которой не было в распознавании
- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет
- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено»
#### Scenario: Строки «БАЛАНС»
- **WHEN** открыта форма `BALANCE`
- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет
#### Scenario: Неверный формат суммы
- **WHEN** аналитик вводит «12,3,4»
- **THEN** запрос не отправляется, поле показывает ошибку формата
### Requirement: Подтверждение документа
Кнопка «Подтвердить данные» SHALL отправлять `POST /documents/{id}/confirm` и быть недоступна для `CONFIRMED` и `ERROR`, а
также при наличии строк `unreadable` / `missing` с суммой или нераспознанным текстом в ячейках (подсказка «Разберите
строки без кода: N» с переходом к первой такой строке). При 409 `CHECKS_NOT_PASSED` экран SHALL показать
`detail.failures[]` и предложить акцепт; при 409 `PERIOD_REQUIRED` — подсказать исправить дату отчёта; при 409
`LINES_UNRESOLVED` — показать тот же текст, что у отключённой кнопки; при 409 `DUPLICATE_FORM` — со ссылкой на
подтверждённую форму; прочие ошибки — `message`. После успеха экран SHALL перейти в режим только чтения.
#### Scenario: Подтверждение с неакцептованными нарушениями
- **WHEN** аналитик подтверждает форму с жёсткими нарушениями без акцепта
- **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием»
#### Scenario: Дубль подтверждённой формы
- **WHEN** сервер отвечает 409 `DUPLICATE_FORM`
- **THEN** экран показывает ошибку со ссылкой на уже подтверждённую форму
## ADDED Requirements
### Requirement: Раскладка строк формы со статусами
Экран проверки формы ББ/ОФР ([41]) SHALL сохранять раскладку «как на бумаге» (Р-14): разделы, внутри — строки `ok` по
возрастанию кода, несколько строк одного кода — в порядке файла (`row_index`), шаблонные строки на своих местах,
пятизначные подстроки под родителем. Строка `unreadable` / `missing` SHALL стоять под ближайшей предыдущей по
`row_index` строкой, размещённой по коду, а без такой — в начале первого раздела; она подсвечена целиком, в ячейке
кода серым — сырой текст `line_code_text` с подсказкой. Строки `detail` SHALL быть скрыты под переключателем
«Показать расшифровки (N)» и показываться под родителем (`parent_row_index`) с отступом. Экран показывает справочное
имя строки (`reference_name`), файловое (`line_name`) — рядом, если отличается. Переключателя «как в файле» нет.
#### Scenario: Код-мусор на своём месте
- **WHEN** строка `unreadable` «ИЧ,250» в файле стоит после строки 1240
- **THEN** на экране она стоит сразу под 1240, подсвечена, в ячейке кода серым «ИЧ,250»
#### Scenario: Расшифровки скрыты
- **WHEN** у формы три строки `detail`
- **THEN** по умолчанию их не видно, переключатель «Показать расшифровки (3)»; включённый показывает их под родителями
### Requirement: Подсветка и предупреждения у места
Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по
`needs_review` строки, поля шапки — по `needs_review_fields` (в подсказке у поля — `header.source.<поле>`: сырой текст и
уверенность). Предупреждения и нарушения (`issues`, `checks`) SHALL показываться у места: у строк (`line_ids`), ячеек
(`line_ids` + `column_id`), полей шапки (`field`) или у документа (в том числе запись с `column_index` без `column_id` —
столбец вне колонок формы); уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized =
false` напрямую нет. У значений строк `detail` `needs_review` всегда `false` (Р-23): при показанных расшифровках их
сомнительные ячейки не подсвечиваются, но подсказка с `text` и уверенностью есть.
#### Scenario: Догадка подтверждена проверкой
- **WHEN** значение `is_recognized = false`, `needs_review = false`
- **THEN** ячейка не подсвечена; в подсказке сырой текст и уверенность
### Requirement: Правка строк, колонок и подтверждение в режиме «Редактировать»
В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника
(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать
расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у
колонки без даты (`period_kind = none`) или с предупреждением о дате (в том числе подтверждение той же даты); снятие подтверждения ячейки и кода (`confirmed:
false`); правка наименования организации в шапке; подтверждение даты отчёта из `needs_review_fields` без изменения (тем
же значением). «Сохранить» отправляет один `PATCH …/statement` (`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами
SHALL быть отключена с подсказкой «Разберите строки без кода: N» и переходом к первой такой строке; ответ сервера 409
`LINES_UNRESOLVED` показывается тем же текстом, 409 `DUPLICATE_FORM` — со ссылкой на подтверждённую форму, 409
`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или
нераспознанным текстом в ячейках.
#### Scenario: Назначить код
- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет
- **THEN** уходит `PATCH` с `lines: [{line_id, line_code: "1250"}]`; после ответа строка встаёт на место 1250
#### Scenario: Подтверждение заблокировано
- **WHEN** у формы две строки `missing` с суммами
- **THEN** «Подтвердить данные» отключена, подсказка «Разберите строки без кода: 2», клик по подсказке ведёт к первой
### Requirement: Предупреждения ОСВ-документа
Экран ОСВ-документа SHALL показывать предупреждения документа из `DocumentOut.issues[]` (коды `OSV_*`) блоком над
таблицей; все записи относятся к документу (строки ОСВ `line_ids` не адресуются); уровень `error` отличается от `info`.
#### Scenario: Предупреждение листа ОСВ
- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*`
- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем

View File

@ -0,0 +1,11 @@
## ADDED Requirements
### Requirement: Предупреждения файла и документа в карточке файла
Карточка файла (панель файлов) SHALL читать `job.warnings` как список структур `{code, severity, message, locator}`
(после выкладки ядра v5.19 это не строки; у старых заданий ядро отдаёт строки, обёрнутые в `LEGACY_WARNING`), показывать
`message` и `locator`, отличая `error` от `info`; предупреждения листа ОСВ и документа — из `DocumentOut.issues[]`.
`job.mapping_warnings` остаётся списком строк.
#### Scenario: Потерянный ОФР в карточке
- **WHEN** у задания `job.warnings = [{code: "JSON_FAILED", severity: "error", message: "…", locator: "file:x_ofr.json"}]`
- **THEN** карточка показывает предупреждение уровня «ошибка» с текстом и местом; экран не падает на объекте вместо строки

View File

@ -0,0 +1,59 @@
## Context
Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`). Шахматка — чистая функция от сводной ОСВ версии,
периметра и выбранных счетов; в БД не хранится (DECISION-5). Корректировки (`correction`) — хранимые ручные решения,
читаются, но в ядре пока никем не создаются.
## Decisions
1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном
`elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL`; иначе 404.
2. Сторона дебета — `company_1`, если `side_1 = D`, иначе `company_2`; кредит — вторая сторона. Счета и исключения
раскрываются по иерархии (`_select_accounts`), как в MANUAL. Нет счетов хотя бы одной стороны — 422
`ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422
`RULE_REQUIRED`.
3. Показатель (`closing`/`turnover`) берётся из правила; ALL и MANUAL остаются на `closing`.
4. Применённые корректировки правила (`status = APPLIED`, тот же `rule_id` и версия консолидации) уменьшают
`amount_a` ячейки `(A, B)` на `debit_adjustment` корректировки (`legal_entity_id = A`, `counterparty_id = B`) и
`amount_b` на `credit_adjustment` корректировки `(B, A)`; расхождение, процент и порог пересчитываются, ID
корректировок попадают в `correction_ids_debit/credit`.
5. `unpaired-osv` строится из `one_sided`-ячеек шахматки пресета (`osv_entry_ids_*`) и возвращает строки сводной ОСВ,
содержащие эти записи.
6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится.
7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_preset-<rule_id>.xlsx`, в шапке «Режим = Пресет»,
коды счетов сторон (отсортированы) и строка «Показатель» (Сальдо/Оборот) для всех режимов.
## Known issues / вопросы владельцу
Спека описывает фактическое поведение PR; пункты ниже не закреплены как окончательные и ждут решения автора и владельца.
1. При пустом `consolidation_version.elimination_rules_version` берутся текущие открытые правила. Это расходится с
DBML («фиксируется при первом расчёте, замораживается при VERIFIED») и критерием AURORA-1536; нужно решение
владельца, в том числе для версий в статусе `VERIFIED`.
2. «Актуальные» правила — `valid_to IS NULL` на сегодня, а не на отчётную дату консолидации.
3. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no`.
4. Семантика корректировки: `(rule, A, B)` дебетовой частью правит ячейку `(A, B)`, кредитовой — ячейку `(B, A)`;
`debit_account_id`, `credit_account_id` и `parent_correction_id` не учитываются; корректировки в ядре пока никто
не создаёт.
5. `unpaired-osv` построена на `one_sided` по наличию строк — это дефект AURORA-1651, и «без пары» здесь не равно
«без пары» по §5 и AURORA-1536.
6. Пресет здесь — правило Прил. 3, а §6 описывает пресеты как наборы счетов; PRESET по ОФР на оборотах
(`measure = turnover`) не подтверждён §5/§6.
7. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit не пишет номер/версию правил
и ID применённых корректировок.
## Decisions made without discussion
- 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена
ошибки: автор переписывает change.
- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues. Обоснование: домен решает
автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой.
- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main
его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть.
- 2026-10-21. Интеграционный тест `test_preset_uses_current_rules_when_version_unfixed` приведён к фактическому
поведению (`version_no is None`, `is_version_fixed is False`), а не к старому ожиданию `version_no == rule.version_no`;
логика вычисления `version_no` не добавлялась. Цена ошибки: если автор решит возвращать номер версии, тест вернуть.
- 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ
(`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая.
- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование
«XLSX шахматки» (ALL/MANUAL) не менялось, поведение PRESET описано отдельным ADDED-требованием.

View File

@ -0,0 +1,34 @@
## Why
Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен
режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3), с учётом применённых корректировок и со
списком строк сводной ОСВ без пары. Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова).
Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в
`design.md`, `## Known issues`, и в спеке не закреплено как окончательное.
## What Changes
- Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`, список
строк без пары `…/preset/{rule_id}/unpaired-osv`.
- Показатель пресета (`closing` / `turnover`) берётся из правила; в ячейке появляются `correction_ids_debit/credit`,
суммы уменьшаются на применённые корректировки правила.
- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`).
- xlsx: для PRESET имя файла с `_preset-<rule_id>`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode`
`ALL`/`MANUAL`/`PRESET` и `rule_id`.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `consolidations-api`: ответ правил элиминации версии.
- `consolidated-osv`: режим PRESET шахматки, корректировки, строки без пары, xlsx пресета.
- `audit-trail`: аудит экспорта шахматки (режим PRESET).
## Impact
Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Ломающее изменение контракта
`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание корректировок,
исправление `one_sided` (AURORA-1651).

View File

@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: Аудит экспорта шахматки
Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/export` и
`GET …/intercompany-matrix/preset/{rule_id}/export`
SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`,
`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора,
`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids,
credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`. `mode` — `ALL`,
`MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
сводной ОСВ продолжает писать собственное событие `EXPORT`.
#### Scenario: Две выгрузки
- **WHEN** пользователь дважды выгружает ту же шахматку
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек
#### Scenario: Выгрузка пресета
- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export`
- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id` правила, пустые массивы счетов и число ячеек

View File

@ -0,0 +1,82 @@
## ADDED Requirements
### Requirement: Шахматка по правилу-пресету
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}`
(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать шахматку в режиме PRESET с тем же ответом, что ALL и MANUAL.
Счета дебета и кредита SHALL браться из правила элиминации `rule_id`: дебет — сторона `company_1`, если
`side_1 = D`, иначе `company_2`; кредит — вторая сторона; исключения (`excluded`) вычитаются, выбранные счета
раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила:
`closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`).
Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же
`version_no`, иначе — без `valid_to`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422:
`RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — неверные `side_1`, `measure` или сторона счёта правила,
`ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`.
#### Scenario: Пресет по сальдо
- **WHEN** правило с `side_1 = D`, `measure = closing` содержит дебетовые счета на стороне 1 и кредитовые на стороне 2
- **THEN** `amount_a` пары — сумма `closing_debit` по счетам стороны 1, `amount_b` — сумма `closing_credit` по счетам стороны 2
#### Scenario: Пресет по оборотам
- **WHEN** у правила `measure = turnover`
- **THEN** суммы считаются по `turnover_debit`/`turnover_credit`
#### Scenario: Правило другой версии
- **WHEN** у версии `elimination_rules_version = 2`, а `rule_id` принадлежит правилу версии 1
- **THEN** 404 для расчёта и выгрузки
#### Scenario: Неизвестное правило
- **WHEN** `rule_id` не существует
- **THEN** 404
### Requirement: Корректировки в шахматке пресета
В режиме PRESET расчёт SHALL учитывать корректировки `correction` со статусом `APPLIED`, у которых
`consolidation_version_id` равен версии, а `rule_id` — правилу пресета. Корректировка с `legal_entity_id = A` и
`counterparty_id = B` SHALL уменьшать `amount_a` ячейки `(A, B)` на `debit_adjustment` и `amount_b` ячейки `(B, A)` на
`credit_adjustment`. Затронутые ячейки SHALL получать пересчитанные `variance_amount`, `variance_percent`,
`exceeds_threshold` и ID корректировок в `correction_ids_debit`/`correction_ids_credit`; у остальных ячеек эти массивы
пусты (поля есть в ответе всех режимов). Корректировки без `legal_entity_id` или `counterparty_id` SHALL игнорироваться.
#### Scenario: Корректировка по дебету
- **WHEN** есть применённая корректировка правила `(A, B)` с `debit_adjustment = 10`
- **THEN** у ячейки `(A, B)` `amount_a` меньше на 10, расхождение пересчитано, `correction_ids_debit` содержит её ID
#### Scenario: Корректировка по кредиту
- **WHEN** есть применённая корректировка правила `(A, B)` с `credit_adjustment = 5`
- **THEN** у ячейки `(B, A)` `amount_b` меньше на 5, `correction_ids_credit` содержит её ID
#### Scenario: Корректировка чужого правила или не применённая
- **WHEN** корректировка относится к другому правилу либо имеет статус не `APPLIED`
- **THEN** суммы ячеек не меняются, `correction_ids_*` пусты
### Requirement: Строки сводной ОСВ без пары по пресету
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv`
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{items, total}`: все строки сводной ОСВ версии, чьи записи ОСВ
входят в `osv_entry_ids_debit/credit` ячеек шахматки пресета с `one_sided = true`. Формат строки — как на странице
сводной ОСВ, без пагинации; `total` — число элементов. Ошибки — как у расчёта пресета.
#### Scenario: Есть односторонняя ячейка
- **WHEN** в шахматке пресета у ячейки `(A, B)` `one_sided = true`
- **THEN** `items` содержит строки сводной ОСВ с записями этой ячейки, `total` равен их числу
#### Scenario: Все пары двусторонние
- **WHEN** у шахматки пресета нет односторонних ячеек
- **THEN** `items = []`, `total = 0`
#### Scenario: Неизвестное правило
- **WHEN** `rule_id` не существует
- **THEN** 404
### Requirement: XLSX шахматки пресета
`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_preset-<rule_id>.xlsx`. Шапка SHALL
содержать режим «Пресет», коды счетов дебета и кредита правила и строку «Показатель» со значением «Сальдо» для
`closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
«Сальдо»).
#### Scenario: Выгрузка по оборотам
- **WHEN** пользователь выгружает пресет с `measure = turnover`
- **THEN** шапка содержит «Режим — Пресет» и «Показатель — Оборот», имя файла заканчивается на `_preset-<rule_id>.xlsx`
#### Scenario: Правило не найдено
- **WHEN** `rule_id` не существует
- **THEN** 404, файл не отдаётся, событие аудита не пишется

View File

@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: Правила элиминации версии
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан
`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а
`rules` — правилами этой версии. Если он не задан, `version_no = null`, `is_version_fixed = false`, а `rules` —
открытые правила (`valid_to IS NULL`); при их отсутствии `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`,
`version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
#### Scenario: Правило с исключением
- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5
- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded`
#### Scenario: Версия правил зафиксирована
- **WHEN** у версии консолидации `elimination_rules_version = 1`
- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1`
#### Scenario: Версия правил не зафиксирована
- **WHEN** у версии консолидации `elimination_rules_version` пуст
- **THEN** ответ содержит `is_version_fixed = false`, `version_no = null` и открытые правила с `valid_to IS NULL`

View File

@ -0,0 +1,4 @@
## 1. Реализация
Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — см.
`design.md`, `## Known issues`.

View File

@ -0,0 +1,81 @@
# consolidated-osv-ui Specification
## Purpose
Аналитик видит сводную ОСВ версии [30]: сводку с несопоставленными строками, пробелы по кварталам, таблицу с фильтрами в заголовках колонок и экспорт в xlsx; сопоставляет контрагента строки с ЮЛ справочника [32].
## Requirements
### Requirement: Сводка версии
Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку
`GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик
«Осталось N» — строки без сопоставленного ЮЛ и без ИНН из файла, arm-core #74; сопоставление — «Указать» в строке),
автоматически сопоставленных.
#### Scenario: Контрольные цифры РБ 2024 Q3
- **WHEN** открыт свод версии РБ 2024 Q3 после подтверждения 22 ОСВ из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}`
- **THEN** «строк» = 1385, «документов» = 22, «несопоставленных» = 1260
### Requirement: Таблица свода и фильтры
Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт,
контрагент, ИНН контрагента, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с
разделителями разрядов) и последней колонкой «Тип контрагента» — бейдж ГСК (синий) / ГК (зелёный) / Внешний (серый), как
в макете [30]. Фильтры — в заголовках колонок: ЮЛ (`legal_entity_id`), корень счёта (`account_root`), поиск по
контрагенту (`q`, ≤ 500 символов), тип контрагента (`counterparty_type`, одно значение — на колонке «Тип контрагента»);
только несопоставленные (`unmapped`) — кнопкой «Показать ЮЛ без ИНН» в баннере и воронкой колонки «ИНН» (подсвечена,
пока фильтр включён, макет [32-osv-no-inn]). «Применить» и «Сбросить» SHALL закрывать окно фильтра.
Смена фильтра SHALL перечитывать первую страницу.
#### Scenario: Строка из контрольных цифр
- **WHEN** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит»
- **THEN** строка `60.01` показывает сальдо нач. Кт 4 454 151.93, обороты Дт 128 058 143.65, Кт 125 950 450.77,
сальдо кон. Кт 2 346 459.05, признак «ГСК»
### Requirement: Ручное сопоставление контрагента
Ячейка ИНН (моноширинный шрифт, по центру) SHALL показывать, как в макете [32-osv-no-inn]:
- ИНН из файла (`counterparty_inn`) — просто текстом, сопоставлена строка или нет (строка с ИНН сопоставления не требует,
решение владельца 08.10.2026, отмена П-1);
- строка сопоставлена, ИНН в файле нет — ИНН ЮЛ справочника (`counterparty_entity_inn`) кнопкой-«таблеткой»
(`osv-inn-mapped-button`); нажатие открывает ту же модалку для пересопоставления, у текущего ЮЛ вместо «Связать» —
неактивное «Связано»;
- нет ни ЮЛ, ни ИНН — подсвеченная ячейка со ссылкой «Указать» (`osv-inn-link-button`).
«Указать» и «таблетка» SHALL открывать модалку
«Выбор организации для связи с <имя контрагента из файла>» [32-osv-map-counterparty]: поиск, таблица ИНН / Организация /
ГК / ГСК / ГСЗ / «Связать», рекомендуемые (`recommended`) — первыми под заголовком «Рекомендуемые» с зелёной подсветкой.
Кандидаты — `GET …/counterparty-candidates?scope=&entry_id=&q=` с паузой 300 мс после ввода: по умолчанию `scope=group`
(периметр и ГК); галочка «Искать во всём справочнике» — `scope=all`, при поиске короче 3 символов запрос не отправляется
и показывается подсказка.
«Связать» SHALL отправлять `POST …/osv/entries/{entry_ids.at(-1)}/counterparty {legal_entity_id}`, после успеха —
сообщение (с кем связано, сколько записей ОСВ сопоставлено автоприменением, создан или перенесён алиас), закрывать
модалку и перечитывать страницу свода и сводку («Осталось N» на клиенте не вычисляется). Ответы 409:
- `ALIAS_TAKEN` — подтверждение «Имя уже закреплено за <владельцы>. Перенести?» → повтор с `move_alias = true`;
- `LOCK_REQUIRED` — подтверждение «Взять версию в работу?» → `POST …/lock` → повтор; отказ — ничего не меняется;
- `LOCKED_BY_OTHER` (от правки или от `POST …/lock`) — «Версию держит <имя>» (имя — из ответа `POST …/lock` или из
`lock` карточки версии), без перехвата;
- `VERSION_NOT_EDITABLE` и прочие ошибки — текстом сервера.
#### Scenario: Сопоставление «Пекарня Счастливочная ООО» в ОСВ РБ
- **WHEN** в своде РБ 2024 Q3 у строки контрагента «Пекарня Счастливочная ООО» без сопоставления нажато «Указать» и у
рекомендуемой «Пекарни Счастливочной» — «Связать» (версия не взята в работу → «Взять в работу»)
- **THEN** строка показывает ЮЛ и тип «ГСК», «Осталось N» уменьшается на число сопоставленных строк, у ЮЛ появляется алиас
#### Scenario: Алиас занят
- **WHEN** имя строки уже алиас другого ЮЛ и аналитик выбирает третье ЮЛ
- **THEN** показан вопрос о переносе с именами владельцев; «Перенести» повторяет запрос с `move_alias = true`
### Requirement: Пробелы
Экран SHALL показывать `gaps[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в
своде отсутствуют.
#### Scenario: Пробелы РБ 2024 Q3
- **WHEN** открыт свод версии РБ 2024 Q3
- **THEN** показано 7 пробелов, у каждого недостающие кварталы «Q1, Q2»
### Requirement: Экспорт
Кнопка «Скачать xlsx» SHALL получать `GET /consolidations/versions/{id}/osv/export` с текущими фильтрами через клиент API
с токеном и сохранять файл; имя — из `Content-Disposition`, а если заголовок недоступен —
`Сводная_ОСВ_<ГК>_<год>_<квартал>.xlsx`.
#### Scenario: Экспорт с фильтром
- **WHEN** аналитик скачивает свод с фильтром по ЮЛ
- **THEN** сохраняется xlsx, а ошибка сервера показывается текстом, а не скачивается

View File

@ -0,0 +1,44 @@
# consolidation-setup Specification
## Purpose
Аналитик создаёт консолидацию ГК за период из модалки [42] (периметр — члены ГСК с возможностью правки), сразу попадает в карточку новой версии и из любой версии переходит к её сводной ОСВ.
## Requirements
### Requirement: Состав модалки «Новая консолидация»
Модалка [42] SHALL предлагать выбор ГК (`GET /directories/company-groups`), ГСК этой ГК с `kind = gsk`
(`GET /directories/company-groups/{id}/related-groups`), года и квартала, и показывать **все** незакрытые ЮЛ ГК
(`GET /directories/legal-entities?company_group_id=&closed=false`, страницы дочитываются по `offset`, пока не получены
все `total`). Члены выбранной ГСК (`GET /directories/related-groups/{id}` → `members[]`) SHALL быть отмечены
автоматически; пользователь может снять или добавить отметки.
#### Scenario: Выбор ГСК отмечает её членов
- **WHEN** аналитик выбирает ГК «Русский бисквит» и её ГСК
- **THEN** в списке ЮЛ видны все ЮЛ ГК, а члены ГСК («АО "Русский бисквит"», «ООО "Пекарня Счастливочная"») отмечены
#### Scenario: ЮЛ больше одной страницы
- **WHEN** у ГК больше ЮЛ, чем `limit` одной страницы
- **THEN** модалка показывает все ЮЛ ГК, а не первую страницу
### Requirement: Создание и переход к версии
По «Создать» модалка SHALL отправить `POST /consolidations {company_group_id, related_group_id, year, quarter,
legal_entity_ids}` с отмеченными ЮЛ. При 201 модалка SHALL закрыться, список консолидаций ГК — перечитаться, а созданная
версия (`VersionCard.id`) — открыться в карточке. При ошибке модалка SHALL остаться открытой и показать `message` из
`ApiError` (коды `CONSOLIDATION_EXISTS`, `RELATED_GROUP_MISMATCH`, `ENTITY_NOT_IN_GROUP`, `PERIMETER_TOO_SMALL` и 422
валидации).
#### Scenario: Успешное создание
- **WHEN** аналитик создаёт консолидацию 2024 Q3 с периметром из ГСК
- **THEN** открыта карточка версии №1 со статусом «Инициация» и двумя ЮЛ в панели «Организации»
#### Scenario: Консолидация за период уже есть
- **WHEN** сервер отвечает 409 `CONSOLIDATION_EXISTS`
- **THEN** модалка не закрывается и показывает текст ошибки сервера
### Requirement: Переход к сводной ОСВ из карточки
Кнопка «К отчётности» карточки версии SHALL вести на `/groups/:group_id/versions/:version_id/osv` и быть доступна в
любом статусе версии.
#### Scenario: Переход к своду
- **WHEN** аналитик нажимает «К отчётности» у версии
- **THEN** открывается экран сводной ОСВ этой версии

View File

@ -0,0 +1,185 @@
# document-review-ui Specification
## Purpose
Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — таблицей строк в порядке файла (как в 1С) только для чтения.
## Requirements
### Requirement: Экран документа
Маршрут `/documents/:document_id` SHALL загрузить `GET /documents/{id}` и показать вид по `document_type`: `BALANCE` и
`INCOME` — проверка формы, `OSV` — просмотр ОСВ; для прочих типов — карточку документа без данных. Шапка экрана SHALL
показывать тип, ЮЛ-владельца, период и статус документа, кнопку «Назад».
#### Scenario: Открытие формы баланса
- **WHEN** аналитик открывает документ `BALANCE`
- **THEN** показан экран проверки формы
### Requirement: Скан рядом с формой
Для ББ/ОФР экран SHALL показывать исходный файл документа: PDF — постранично через pdf.js из байт
`GET /files/{file_id}/content?disposition=inline`, полученных клиентом API с токеном (не через `<iframe>` и не прямой
ссылкой); изображение (png/jpg) — тегом `<img>` из blob; xlsx — без превью, с кнопкой «Скачать исходник».
#### Scenario: PDF под CSP sandbox
- **WHEN** сервер отдаёт PDF с `Content-Security-Policy: sandbox`
- **THEN** страницы PDF отрисованы на canvas и листаются
### Requirement: Форма ББ/ОФР — просмотр и правка
Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при
правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая
строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `needs_review = true` SHALL
подсвечиваться как «требует внимания» (подсветки напрямую по `confidence < 100` или `is_recognized = false` нет), с
`is_modified = true` — как «исправлено» с исходным значением `original_amount` в подсказке; пустая достроенная ячейка не
подсвечивается. Строки «БАЛАНС» формы `BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом;
подсветка ячейки перекрывает фон строки. Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не
`CONFIRMED`, ячейка SHALL быть редактируемой: ввод проверяется по формату суммы (до 2 знаков после точки, знак минус
допустим, пустое — `null`), сохранение отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой:
ячейка строки из БД — `{line_id, column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code,
column_id, amount}`; ответ заменяет состояние экрана. Правка шапки (ИНН, дата отчёта, единицы, наименование
организации) SHALL отправляться тем же `PATCH` в `header`; `inn: null` не отправляется. Правка строк, колонок и
подтверждений в том же `PATCH` (`columns`, `lines`, `values[].confirmed`) описана в требовании «Правка строк, колонок и
подтверждение в режиме «Редактировать»».
#### Scenario: Исправление ячейки
- **WHEN** аналитик меняет значение ячейки и сохраняет
- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят
#### Scenario: Заполнение строки, которой не было в распознавании
- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет
- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено»
#### Scenario: Строки «БАЛАНС»
- **WHEN** открыта форма `BALANCE`
- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет
#### Scenario: Неверный формат суммы
- **WHEN** аналитик вводит «12,3,4»
- **THEN** запрос не отправляется, поле показывает ошибку формата
### Requirement: Проверки и их акцепт
Экран SHALL показывать `checks`: жёсткие (`failed`), мягкие (`soft_failed`), межформенную (`cross_form`) и акцепт
(`accepted`: кто, когда, комментарий). При непустом `failed` и `accepted = null` SHALL быть доступно «Принять с
комментарием»: комментарий 1–2000 символов → `POST /documents/{id}/statement/checks/accept {comment}`.
#### Scenario: Акцепт нарушений
- **WHEN** есть жёсткие нарушения и аналитик принимает их с комментарием
- **THEN** блок проверок показывает акцепт, нарушения остаются видимыми
### Requirement: Подтверждение документа
Кнопка «Подтвердить данные» SHALL отправлять `POST /documents/{id}/confirm` и быть недоступна для `CONFIRMED` и `ERROR`, а
также при наличии строк `unreadable` / `missing` с суммой или нераспознанным текстом в ячейках (подсказка «Разберите
строки без кода: N» с переходом к первой такой строке). При 409 `CHECKS_NOT_PASSED` экран SHALL показать
`detail.failures[]` и предложить акцепт; при 409 `PERIOD_REQUIRED` — подсказать исправить дату отчёта; при 409
`LINES_UNRESOLVED` — показать тот же текст, что у отключённой кнопки; при 409 `DUPLICATE_FORM` — со ссылкой на
подтверждённую форму; прочие ошибки — `message`. После успеха экран SHALL перейти в режим только чтения.
#### Scenario: Подтверждение с неакцептованными нарушениями
- **WHEN** аналитик подтверждает форму с жёсткими нарушениями без акцепта
- **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием»
#### Scenario: Дубль подтверждённой формы
- **WHEN** сервер отвечает 409 `DUPLICATE_FORM`
- **THEN** экран показывает ошибку со ссылкой на уже подтверждённую форму
### Requirement: ОСВ-документ
Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) таблицей в порядке
файла, как в 1С (AURORA-1702): строки счёта, субсчетов и подразделений, у которых есть вложенные строки, — строки-группы
(жирным, со своими сальдо и оборотами), вложенные строки — с отступом по уровню в колонке аналитики. Договоры отдельными
строками не идут — их сумма в строке контрагента; субсчёт или подразделение без вложенных строк и без сумм не выводится.
Строки-показатели колонки «Показатели» и строки «Кол.», «Вал.» (§1.3 правил предметной области: `group_type = OTHER`,
`data_type` не BU/SUM и не равен виду родителя — NU, QUANTITY, CURRENCY или пусто у «ПР»/«ВР») в рублёвую таблицу
SHALL NOT попадать и родителя группой не делают: строка БУ остаётся строкой с наименованием и суммами в рублях
(AURORA-1703). Колонки: субсчёт, подразделение (если в файле есть этот уровень), аналитика (у строки счёта — её текст),
ИНН (если есть контрагенты), сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с
разделителями разрядов, рубли), только чтение. Фильтр по счёту оставляет счёт и все его субсчета («62» — 62, 62.01,
62.02), при поиске по аналитике или ИНН строки-группы скрываются. Кнопка «Подтвердить» — как для форм.
Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения,
строки-группы и строки-показатели не учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name`
(«Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке
заголовка. Колонка «ИНН» и подсказка поиска «Контрагент или ИНН» SHALL показываться только при наличии контрагентов
(`COUNTERPARTY`); иначе колонки ИНН нет, а поиск подписан «Поиск по аналитике».
#### Scenario: ОСВ по основным средствам
- **WHEN** листья документа — аналитика с `group_name` «Основные средства», контрагентов нет
- **THEN** колонка называется «Основные средства», колонки ИНН нет, поиск подписан «Поиск по аналитике»
#### Scenario: Строки счёта и субсчетов
- **WHEN** аналитик открывает ОСВ по счёту 62 с субсчетами 62.01 и 62.02 и контрагентами под ними
- **THEN** в таблице жирным видны строки 62, 62.01, 62.02 с их сальдо и оборотами, контрагенты — под своим субсчётом с отступом
#### Scenario: ОСВ с колонкой «Показатели»
- **WHEN** у позиции номенклатуры счёта 41 в файле строки «БУ» и «Кол.»
- **THEN** в таблице строка позиции с наименованием и суммами в рублях, строки «Кол.» нет
#### Scenario: Подтверждение ОСВ
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
- **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION`
### Requirement: Виртуализация таблицы ОСВ-документа
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по счёту — клиентские;
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без
падения вкладки.
#### Scenario: Большая ОСВ
- **WHEN** аналитик открывает ОСВ-документ на ~29 000 строк
- **THEN** документ открывается, число строк в DOM не зависит от размера документа, прокрутка до конца, поиск и фильтр по субсчёту работают, «Итого по документу» совпадает с суммой по всему документу
### Requirement: Раскладка строк формы со статусами
Экран проверки формы ББ/ОФР ([41]) SHALL сохранять раскладку «как на бумаге» (Р-14): разделы, внутри — строки `ok` по
возрастанию кода, несколько строк одного кода — в порядке файла (`row_index`), шаблонные строки на своих местах,
пятизначные подстроки под родителем. Строка `unreadable` / `missing` SHALL стоять под ближайшей предыдущей по
`row_index` строкой, размещённой по коду, а без такой — в начале первого раздела; она подсвечена целиком, в ячейке
кода серым — сырой текст `line_code_text` с подсказкой. Строки `detail` SHALL быть скрыты под переключателем
«Показать расшифровки (N)» и показываться под родителем (`parent_row_index`) с отступом. Экран показывает справочное
имя строки (`reference_name`), файловое (`line_name`) — рядом, если отличается. Переключателя «как в файле» нет.
#### Scenario: Код-мусор на своём месте
- **WHEN** строка `unreadable` «ИЧ,250» в файле стоит после строки 1240
- **THEN** на экране она стоит сразу под 1240, подсвечена, в ячейке кода серым «ИЧ,250»
#### Scenario: Расшифровки скрыты
- **WHEN** у формы три строки `detail`
- **THEN** по умолчанию их не видно, переключатель «Показать расшифровки (3)»; включённый показывает их под родителями
### Requirement: Подсветка и предупреждения у места
Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по
`needs_review` строки, поля шапки — по `needs_review_fields` (в подсказке у поля — `header.source.<поле>`: сырой текст и
уверенность). Предупреждения и нарушения (`issues`, `checks`) SHALL показываться у места: у строк (`line_ids`), ячеек
(`line_ids` + `column_id`), полей шапки (`field`) или у документа (в том числе запись с `column_index` без `column_id` —
столбец вне колонок формы); уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized =
false` напрямую нет. У значений строк `detail` `needs_review` всегда `false` (Р-23): при показанных расшифровках их
сомнительные ячейки не подсвечиваются, но подсказка с `text` и уверенностью есть.
#### Scenario: Догадка подтверждена проверкой
- **WHEN** значение `is_recognized = false`, `needs_review = false`
- **THEN** ячейка не подсвечена; в подсказке сырой текст и уверенность
### Requirement: Правка строк, колонок и подтверждение в режиме «Редактировать»
В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника
(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать
расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у
колонки без даты (`period_kind = none`) или с предупреждением о дате (в том числе подтверждение той же даты); снятие подтверждения ячейки и кода (`confirmed:
false`); правка наименования организации в шапке; подтверждение даты отчёта из `needs_review_fields` без изменения (тем
же значением). «Сохранить» отправляет один `PATCH …/statement` (`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами
SHALL быть отключена с подсказкой «Разберите строки без кода: N» и переходом к первой такой строке; ответ сервера 409
`LINES_UNRESOLVED` показывается тем же текстом, 409 `DUPLICATE_FORM` — со ссылкой на подтверждённую форму, 409
`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или
нераспознанным текстом в ячейках.
#### Scenario: Назначить код
- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет
- **THEN** уходит `PATCH` с `lines: [{line_id, line_code: "1250"}]`; после ответа строка встаёт на место 1250
#### Scenario: Подтверждение заблокировано
- **WHEN** у формы две строки `missing` с суммами
- **THEN** «Подтвердить данные» отключена, подсказка «Разберите строки без кода: 2», клик по подсказке ведёт к первой
### Requirement: Предупреждения ОСВ-документа
Экран ОСВ-документа SHALL показывать предупреждения документа из `DocumentOut.issues[]` (коды `OSV_*`) блоком над
таблицей; все записи относятся к документу (строки ОСВ `line_ids` не адресуются); уровень `error` отличается от `info`.
#### Scenario: Предупреждение листа ОСВ
- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*`
- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем

View File

@ -0,0 +1,77 @@
# primary-files-ui Specification
## Purpose
Аналитик загружает первичные файлы ЮЛ в контексте версии и видит в панели «Первичные документы» [39]/[40] файлы и их документы со статусами распознавания; список обновляется сам, пока идёт распознавание.
## Requirements
### Requirement: Загрузка файлов ЮЛ в контексте версии
Панель «Файлы ЮЛ» [39]/[40], открытая из строки ЮЛ карточки версии, SHALL загружать выбранные файлы одним запросом
`POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (multipart, поле `files` повторяется),
где `consolidation_id` — консолидация открытой версии (из списка консолидаций ГК в сторе по `current_version_id`),
без других полей формы и без query `consolidation_version_id`. Тип и период документа не спрашиваются. Кнопка
«Загрузить документы» SHALL быть недоступна с подсказкой «Загрузка доступна только в версии в работе», если статус
открытой версии не `INITIATED`, `DOCUMENTS_PROCESSING` или `DATA_CORRECTION`. После ответа панель SHALL показать итог:
принятые файлы (`accepted[].name`) и отклонённые с причиной (`rejected[].name` + `message`). Ответ 422 с телом
`UploadResult` SHALL трактоваться как «ни один файл не принят» с тем же показом `rejected[]`; ответ 422 с `detail`
(`ENTITY_NOT_IN_PERIMETER`), 409 (`CONSOLIDATION_NOT_EDITABLE`) и прочие ошибки — показом `message`.
#### Scenario: Часть файлов отклонена
- **WHEN** аналитик загружает ОСВ-файл и файл, уже загруженный ранее у этого ЮЛ
- **THEN** первый показан принятым, второй — отклонённым с текстом причины `DUPLICATE_FILE` от сервера
#### Scenario: Все файлы отклонены
- **WHEN** сервер отвечает 422 с телом `UploadResult`
- **THEN** панель показывает каждый отклонённый файл с причиной, а не общую ошибку
#### Scenario: Загрузка идёт в консолидацию
- **WHEN** панель открыта из версии консолидации 2024 Q3 и аналитик загружает файл
- **THEN** запрос уходит на `/consolidations/{id консолидации 2024 Q3}/legal-entities/{id ЮЛ}/files` без query; после
приёма файл появляется в столе панели
#### Scenario: Версия не в работе
- **WHEN** панель открыта из версии в статусе `CANCELLED`
- **THEN** кнопка «Загрузить документы» недоступна, при наведении — подсказка
### Requirement: Список файлов и документов ЮЛ
Панель SHALL по умолчанию показывать рабочий стол консолидации открытой версии —
`GET /consolidations/{consolidation_id}/legal-entities/{id}/files` (новые первыми): имя, статус файла по-русски
(«Обработка» для `UPLOADED`/`PROCESSING`, «Ошибка» с причиной из `job` для `ERROR`) и вложенные `documents[]` — тип
(«ОСВ <счёт>», «Бух. баланс», «ОФР», «Расшифровка», «Прочее»), период («2024 К1», «2024 · 9 мес.», «период не
определён»), статус документа («Требуется проверка», «Загружен» для `CONFIRMED`, «Ошибка»). Документы `OSV`, `BALANCE`,
`INCOME` SHALL быть ссылками на `/documents/:document_id`. Переключатель «Все документы организации» SHALL переключать
панель на `GET /legal-entities/{id}/files`; в этом режиме у файла не из текущей консолидации в подписи SHALL стоять его
консолидация: «<год> К<n>» по списку консолидаций ГК, «вне консолидации» при `consolidation_id = null`, «другая
консолидация», если id нет в списке. Если консолидация открытой версии не определена (версии нет в списке ГК), панель
SHALL работать в режиме «все» без переключателя.
#### Scenario: PDF с двумя формами
- **WHEN** файл `aep_6.pdf` распознан
- **THEN** под файлом видны два документа — «Бух. баланс» и «ОФР» со статусом «Требуется проверка», каждый открывает
экран проверки
#### Scenario: Стол и все документы
- **WHEN** у ЮЛ есть файлы, загруженные в консолидации 2024 Q3 и 2024 Q4, и файл, загруженный без контекста; панель
открыта из версии 2024 Q3
- **THEN** по умолчанию виден только файл 2024 Q3; после переключателя «Все документы организации» видны все три, у
файла 2024 Q4 подпись «2024 К4», у файла без контекста — «вне консолидации», у файла 2024 Q3 подписи нет
### Requirement: Опрос до окончания распознавания
Пока панель открыта и среди файлов активного списка (стол или все документы) есть `UPLOADED` или `PROCESSING`, панель
SHALL перечитывать этот список раз в 5 секунд и SHALL прекратить опрос, когда таких файлов нет или панель закрыта.
Переключение режима или смена ЮЛ SHALL сбрасывать список и перечитывать его. При закрытии панели карточка версии SHALL
перечитываться (обновить ячейки чек-листа).
#### Scenario: Опрос останавливается
- **WHEN** все файлы активного списка перешли в `PROCESSED` или `ERROR`
- **THEN** запросы списка файлов прекращаются
### Requirement: Предупреждения файла и документа в карточке файла
Карточка файла (панель файлов) SHALL читать `job.warnings` как список структур `{code, severity, message, locator}`
(после выкладки ядра v5.19 это не строки; у старых заданий ядро отдаёт строки, обёрнутые в `LEGACY_WARNING`), показывать
`message` и `locator`, отличая `error` от `info`; предупреждения листа ОСВ и документа — из `DocumentOut.issues[]`.
`job.mapping_warnings` остаётся списком строк.
#### Scenario: Потерянный ОФР в карточке
- **WHEN** у задания `job.warnings = [{code: "JSON_FAILED", severity: "error", message: "…", locator: "file:x_ofr.json"}]`
- **THEN** карточка показывает предупреждение уровня «ошибка» с текстом и местом; экран не падает на объекте вместо строки

27
web/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,27 @@
# Архитектура фронта arm-core/web
SPA поверх API arm-core (`/api/v1`). Своих данных и бизнес-вычислений нет: всё производное (чек-лист, сводная ОСВ,
статусы) считает сервер, фронт показывает и отправляет действия пользователя. До 21.10.2026 фронт вёлся в отдельном
репозитории `RSHB/arm-web` и переносился сюда синхронизацией; теперь — только здесь.
## Модули
| Модуль | Ответственность | Зависит от |
|---|---|---|
| `src/api/` | HTTP-клиент, токен, разбор ошибок | `conf/`, `auth/token` |
| `src/auth/` | профиль `/me`, dev-вход, права для меню | `api/` |
| `src/services/api/` | вызовы ресурсов arm-core | `api/`, `types/` |
| `src/store/` | состояние экранов (Redux Toolkit) | `services/api/` |
| `src/utils/` | чистые функции: деньги, подписи, скачивание | `types/` |
| `src/types/` | DTO API (повторяют `../api/src/schemas/`) | — |
| `src/components/` | примитивы UI (таблица, теги, заголовок) | antd |
| `src/widgets/` | блоки экранов (`DocumentReview` — проверка документа со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer`) | `components/`, `store/`, `services/api/`, `utils/` |
| `src/layouts/` | страницы-маршруты | `widgets/` |
## Границы
- Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается.
- `components/` не знают про store и API; `types/` ни от чего не зависят.
- `utils/` не импортирует React, store и API.
## Сборка
`npm run build` → `web/dist`, Dockerfile ядра кладёт его в `api/web/` и FastAPI отдаёт SPA (`../README.md`).

67
web/CLAUDE.md Normal file
View File

@ -0,0 +1,67 @@
# CLAUDE.md — arm-core/web (фронт АРМ КА)
Общий контекст — `../../CLAUDE.md` (workspace) и `../CLAUDE.md` (arm-core). Этот файл — только про фронт.
Язык общения, коммитов, комментариев и документации — русский; идентификаторы — английские, как в DBML и API.
## Что это
SPA кредитного аналитика АРМ КА: справочники групп, консолидации ГК по периодам, карточка версии с чек-листом
документов, загрузка и проверка первичных файлов (ОСВ, ББ/ОФР из PDF), сводная ОСВ и шаги консолидации.
Бэкенд — `../api` (`/api/v1`, единственный источник данных). С 21.10.2026 фронт ведётся здесь: задачи — worktree arm-core,
ветка `feat/<issue>-<slug>`, PR в `main`. Прежний репозиторий `RSHB/arm-web` заморожен на `test` `de4e477`
(= arm-core `3157a15`), в нём осталась история (номера вида `arm-web#7` в комментариях кода — оттуда). Открытые issue
arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), спеки экранов и архив их изменений — в `../openspec/`
(архивы с суффиксом `-web` — UI-половины изменений, у которых бэкенд-половина лежит под тем же именем без суффикса).
## Источники истины для фронта
1. OpenAPI arm-core и гайд `../../docs/dev/frontend-api-guide.md` — эндпоинты, тела, коды ошибок. Типы в `src/types/`
повторяют схемы бэка (`../api/src/schemas/`), поля не придумываем.
2. Пользовательский путь — `../../docs/context/10-user-journey.md`; макет — `../../docs/mockup/` (по одному экрану через
`../../docs/mockup/INDEX.md`).
3. Права — данные: доступ решает сервер (403). Меню пока не скрывается по правам (`Sidebar` не применяет permission);
коды прав из `/me` лежат в `src/auth/permissions.ts`.
- Требования к экранам: действующие — `../openspec/specs/*-ui/spec.md` (перенесены из arm-web@de4e477), новые и
изменённые — `../openspec/changes/` этого репозитория.
- `../../reference-old-arm/` — только чтение: инфраструктурные идеи (PDFResultViewer) можно смотреть, эндпоинты старого
АРМ (`/fsl/...`, `/osv/by-file-id`, `POST /files`) в arm-core не существуют.
## Стек и ограничения
- React 18 + TypeScript strict, rsbuild, antd 6 (основной UI-кит; daisyui/tailwind — из шаблона), react-router 7,
Redux Toolkit, pdfjs-dist (legacy-сборка — для Яндекс Браузера, см. `src/widgets/DocumentReview/pdfjs.ts`).
- Пакеты `raisa-*` — `optionalDependencies` (вне банка недоступны); `package-lock.json` в git не хранится (в банке
зависимости ставятся через Nexus). В контуре `package.json` урезается до сборки
(`../../docs/dev/contour-transfer-guide.md` §3.2): новые пакеты, нужные сборке, добавлять и в разрешённый список там.
- env только `REACT_APP_*` через `src/conf/config.ts`; `REACT_APP_API_URL` — адрес arm-core, `REACT_APP_ROOT_PATH` =
`ROOT_PATH` бэка.
## Архитектура
Карта модулей и границы — `ARCHITECTURE.md`. Главное:
- `src/api/` — единый HTTP-клиент `apiFetch` / `api.get|post|put|patch|del`: префикс `${API_URL}/api/v1`, Bearer-токен,
`ApiError {status, code, message}` по трём формам `detail`, событие `armka:unauthorized` на 401. **Все запросы — только
через него** (прямой `fetch` теряет токен и разбор ошибок). `apiFetchBlob(path) → {blob, filename}` — бинарные ответы
(скан PDF, экспорт xlsx) с той же обработкой токена/401/ошибок.
- `src/services/api/` — функции по ресурсам arm-core (consolidations, files, documents, osv, directories/…).
- `src/utils/` — чистые функции без React/store/API: деньги (`money.ts`, суммы — строки), подписи статусов/типов/периодов
(`labels.ts`), скачивание и имя файла (`download.ts`).
- `src/auth/` — `AuthGate` (профиль `/me`), `DevLogin` (`POST /dev/token`, только не в production), `token.ts`.
- `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`).
- `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и
`/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ
со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ), `src/components/` — примитивы
(`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API.
## Команды (из `web/`)
- `npm install`; `npm test` — vitest; `npm run lint` — ESLint + `prettier --check`; `npm run format` — **перед PR**
(`eslint --fix` → `biome check --write` → `prettier --write`).
- `REACT_APP_ROOT_PATH=/ npm run start` — dev-сервер на :3000 (без переменной приложение ждёт префикс
`/aurora/apps/fastapi-dkb-armka` и открывается белый экран) с proxy `/api` → `ARM_CORE_URL` (по умолчанию
`http://localhost:8000`); бэкенд — локальный стенд по `../../docs/dev/qa-guide.md` §4.
- Из корня arm-core: `make web-check` (install + biome + `tsc --noEmit` + build, входит в `check-full`). Перед PR фронта
зелёными должны быть `make web-check`, `npm test` и `npm run lint`; вывод — в описание PR.
## Тесты
Unit-тесты — vitest на чистых функциях (`src/**/*.test.ts`); компоненты не тестируются — ручной сценарий по стенду с
фикстурами из `../../fixtures` (`../../docs/dev/qa-guide.md` §8, `../../docs/scripts/demo-prepare.sh`). Экран сверять с
макетом и проходить сценарий в браузере до сдачи.
## Для владельца
Пользователь слаб во фронте: на TS/React-изменения агент делает код-ревью сам и объясняет решения простым языком.

View File

@ -1,34 +1,35 @@
# arm-web — фронт АРМ КА (демо-прототип) # arm-core/web — фронт АРМ КА
SPA кредитного аналитика поверх API `arm-core` (`/api/v1`). Контекст системы — `../CLAUDE.md`, архитектура — SPA кредитного аналитика поверх API `arm-core` (`/api/v1`). Контекст системы — `../../CLAUDE.md`, ядро — `../README.md`,
`ARCHITECTURE.md`, соглашения репозитория — `CLAUDE.md`. архитектура фронта — `ARCHITECTURE.md`, соглашения — `CLAUDE.md`. С 21.10.2026 фронт ведётся здесь (PR в arm-core
`main`); прежний репозиторий `RSHB/arm-web` заморожен.
## Запуск ## Запуск
1. `make install`. 1. `npm install` (из `web/`).
2. Стенд arm-core — по `../docs/dev/qa-guide.md` §4 (ядро на `localhost:8000`, `DEBUG=true`, `make seed-dev`); для 2. Стенд arm-core — по `../../docs/dev/qa-guide.md` §4 (ядро на `localhost:8000`, `DEBUG=true`, `make seed-dev`); для
распознавания PDF — профиль `services` (§6.8: tabular-parser, scan-gateway, scan-emulator). распознавания PDF — профиль `services` (§6.8: tabular-parser, scan-gateway, scan-emulator).
3. `make start` — dev-сервер rsbuild (сам ставит `REACT_APP_ROOT_PATH=/`; без этого приложение ждёт префикс 3. `REACT_APP_ROOT_PATH=/ npm run start` — dev-сервер rsbuild (без `REACT_APP_ROOT_PATH=/` приложение ждёт префикс
`/aurora/apps/fastapi-dkb-armka` и показывает белый экран, см. `qa-guide` §4.3). Запросы `/api` проксируются на `/aurora/apps/fastapi-dkb-armka` и показывает белый экран, см. `qa-guide` §4.3). Запросы `/api` проксируются на
`ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен. `ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен.
4. Вход — через dev-форму (`POST /dev/token`), например `ka_go@armka.local`. 4. Вход — через dev-форму (`POST /dev/token`), например `ka_go@armka.local`.
## Форматирование и линтеры ## Форматирование и линтеры
**Перед PR — `npm run format`** (или `make format`): `eslint --fix` → `biome check --write` (импорты, безопасные **Перед PR — `npm run format`**: `eslint --fix` → `biome check --write` (импорты, безопасные
исправления) → `prettier --write`. Команда приводит `src/` к единому виду; повторный запуск ничего не меняет. исправления) → `prettier --write`. Команда приводит `src/` к единому виду; повторный запуск ничего не меняет.
- Prettier (`.prettierrc.json`) — единственный форматтер: табы, ширина 140, одинарные кавычки (в CSS — двойные), - Prettier (`.prettierrc.json`) — единственный форматтер: табы, ширина 140, одинарные кавычки (в CSS — двойные),
`>` тега JSX на той же строке. Форматтер biome выключен, чтобы два инструмента не спорили о пробелах. `>` тега JSX на той же строке. Форматтер biome выключен, чтобы два инструмента не спорили о пробелах.
- ESLint (`eslint.config.js`) — правила, повторяющие `biome.json`; biome остаётся линтером в `make lint`/`make check`. - ESLint (`eslint.config.js`) — правила, повторяющие `biome.json`; biome остаётся линтером в `make web-check` ядра.
- `npm run lint` — `eslint` + `prettier --check` без исправлений: ошибки валят команду, предупреждения (`any`, - `npm run lint` — `eslint` + `prettier --check` без исправлений: ошибки валят команду, предупреждения (`any`,
`console`, зависимости хуков) — нет. `console`, зависимости хуков) — нет.
- В редакторе: расширения Prettier и ESLint, «Format on Save» с Prettier — результат совпадёт с `npm run format`. - В редакторе: расширения Prettier и ESLint, «Format on Save» с Prettier — результат совпадёт с `npm run format`.
## Проверка ## Проверка
`make check` — install, biome + ESLint + Prettier (`make lint`), `tsc --noEmit`, vitest, `rsbuild build`. Зелёным Из корня arm-core — `make web-check` (install, biome, `tsc --noEmit`, `rsbuild build`; входит в `check-full`), из `web/` —
считается всё вместе. `npm test` (vitest) и `npm run lint` (ESLint + Prettier). Зелёным считается всё вместе.
## Демо-сценарий ## Демо-сценарий
@ -43,9 +44,9 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
`CONSOLIDATION_EXISTS`) → члены ГСК отмечены → «Создать» → открывается страница группы с новой версией. `CONSOLIDATION_EXISTS`) → члены ГСК отмечены → «Создать» → открывается страница группы с новой версией.
2. Дальше работаем на подготовленной версии 2024 Q3 (`/groups/<id>?version=0437f8be-019c-4dbb-9776-08c306aa38cc`): 2. Дальше работаем на подготовленной версии 2024 Q3 (`/groups/<id>?version=0437f8be-019c-4dbb-9776-08c306aa38cc`):
панель «Организации» → ЮЛ → «Файлы» → документы ОСВ в статусе «Загружен». Загрузка ОСВ вручную — панель «Организации» → ЮЛ → «Файлы» → документы ОСВ в статусе «Загружен». Загрузка ОСВ вручную —
`../fixtures/osv/{rb,schastl}/2024-Q{1,2,3}/*`; для новой версии быстро — `scripts/demo-prepare.sh <version_id> [email]` `../../fixtures/osv/{rb,schastl}/2024-Q{1,2,3}/*`; для новой версии быстро — `../../docs/scripts/demo-prepare.sh <version_id> [email]`
(каталог фикстур — `FIXTURES_DIR`, по умолчанию `../../fixtures` относительно репозитория; в конце печатает сводку). (каталог фикстур — `FIXTURES_DIR`, по умолчанию `fixtures` workspace; в конце печатает сводку).
3. ББ/ОФР: открыть уже загруженный ББ Счастливочной (или загрузить `../fixtures/ocr/aep_6.pdf` РБ) → скан слева, форма справа, 3. ББ/ОФР: открыть уже загруженный ББ Счастливочной (или загрузить `../../fixtures/ocr/aep_6.pdf` РБ) → скан слева, форма справа,
исправить ячейку. ИНН и дата отчёта в aep_6 не распознаются: в шапке ввести дату строго `2024-09-30` (ГГГГ-ММ-ДД) и исправить ячейку. ИНН и дата отчёта в aep_6 не распознаются: в шапке ввести дату строго `2024-09-30` (ГГГГ-ММ-ДД) и
нажать «Сохранить», иначе «Подтвердить данные» даст 409 `PERIOD_REQUIRED`. Принять нарушения с комментарием (если есть), подтвердить. нажать «Сохранить», иначе «Подтвердить данные» даст 409 `PERIOD_REQUIRED`. Принять нарушения с комментарием (если есть), подтвердить.
4. «К отчётности» → сводная ОСВ; фильтр «Счастливочная» + «бисквит» → строка `60.01` (сальдо нач. Кт 4 454 151.93, 4. «К отчётности» → сводная ОСВ; фильтр «Счастливочная» + «бисквит» → строка `60.01` (сальдо нач. Кт 4 454 151.93,
@ -62,13 +63,11 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
(«Показать все»); в режиме правки можно назначить код строке, пометить её расшифровкой и подтвердить значение, код или дату («Показать все»); в режиме правки можно назначить код строке, пометить её расшифровкой и подтвердить значение, код или дату
колонки — всё уходит одним `PATCH`; кнопка «Подтвердить данные» заблокирована, пока есть строки без кода (ссылка колонки — всё уходит одним `PATCH`; кнопка «Подтвердить данные» заблокирована, пока есть строки без кода (ссылка
«Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа. «Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа.
Карта модулей — в `CLAUDE.md`, раздел «Архитектура». Карта модулей — `ARCHITECTURE.md`.
## Ограничения прототипа ## Ограничения
- Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов, - Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов,
смены периметра и ручных переходов статусов. смены периметра и ручных переходов статусов.
- Нет правки строк ОСВ (в API нет), картинок страниц и координат ячеек в скане (в API нет). - Нет правки строк ОСВ (в API нет), картинок страниц и координат ячеек в скане (в API нет).
- Нет генерации типов из OpenAPI и автотестов компонентов (vitest — только чистые функции). - Нет генерации типов из OpenAPI и автотестов компонентов (vitest — только чистые функции).
- Нет сборки под контур и переноса в `arm-core/web` — отдельной задачей после демо.
- Изменений arm-core / scan-gateway нет: найденные пробелы — отдельные issue в тех репозиториях.

View File

@ -127,6 +127,8 @@ export interface IssueCountsOut {
} }
/** Уровень строки ОСВ (DBML `osv_group_type`). */ /** Уровень строки ОСВ (DBML `osv_group_type`). */
export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER'; export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER';
/** Вид сумм строки ОСВ (`osv_data_type`): BU, SUM — рубли; NU, CURRENCY, QUANTITY — строки-показатели под строкой БУ. */
export type OsvDataType = 'BU' | 'NU' | 'CURRENCY' | 'QUANTITY' | 'SUM';
export interface EntryOut { export interface EntryOut {
id: string; id: string;
@ -136,6 +138,7 @@ export interface EntryOut {
row_value: string | null; row_value: string | null;
group_type: OsvGroupType | null; group_type: OsvGroupType | null;
group_name: string | null; group_name: string | null;
data_type: OsvDataType | null;
account_code: string | null; account_code: string | null;
counterparty_name: string | null; counterparty_name: string | null;
counterparty_inn: string | null; counterparty_inn: string | null;

View File

@ -10,11 +10,15 @@ import { IssuesPanel } from './IssuesPanel';
import { confirmButtonStyle } from './ReviewCard'; import { confirmButtonStyle } from './ReviewCard';
import { expectedActualText, failureKey } from './checkText'; import { expectedActualText, failureKey } from './checkText';
import { confirmErrorInfo } from './confirmError'; import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows'; import { AMOUNT_KEYS, type AmountKey, type OsvRow, filterRows, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize'; import { osvBodyHeight } from './osvTableSize';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */ /** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160; const MONEY_WIDTH = 160;
/** Отступ одного уровня вложенности в колонке аналитики, px. */
const INDENT = 16;
/** Строки-группы (счёт, субсчёт, подразделение с вложенными строками) — жирным, как промежуточные итоги в 1С. */
const groupRowStyle = { fontWeight: 600 };
const money = (key: AmountKey, title: string) => ({ const money = (key: AmountKey, title: string) => ({
key, key,
@ -48,7 +52,8 @@ function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle, title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
dataIndex: 'analytics', dataIndex: 'analytics',
width: 320, width: 320,
render: (v: string | null) => v ?? '—', // отступ по уровню вложенности — дерево строк как в 1С
render: (v: string | null, row: OsvRow) => <span style={{ paddingLeft: row.depth * INDENT }}>{v ?? '—'}</span>,
}, },
...(hasCounterparties ? [{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 }] : []), ...(hasCounterparties ? [{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 }] : []),
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]), group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
@ -57,15 +62,6 @@ function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
]; ];
} }
/** Поиск по аналитике (и по ИНН, если он есть) без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
const q = query.trim().toLowerCase();
return rows.filter(
(row) =>
(!account || row.account === account) && (!q || (row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)),
);
}
interface ConfirmProblem { interface ConfirmProblem {
code: string | undefined; code: string | undefined;
message: string; message: string;
@ -164,7 +160,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
/> />
<Select <Select
allowClear allowClear
placeholder='Субсчёт' placeholder='Счёт / субсчёт'
style={{ width: 160 }} style={{ width: 160 }}
value={account} value={account}
onChange={setAccount} onChange={setAccount}
@ -199,6 +195,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
bordered bordered
size='small' size='small'
rowKey='key' rowKey='key'
onRow={(row) => ({ style: row.isGroup ? groupRowStyle : undefined })}
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={rows} dataSource={rows}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { EntryOut, OsvGroupType } from '../../types/api'; import type { EntryOut, OsvGroupType } from '../../types/api';
import { flattenEntries } from './osvRows'; import { filterRows, flattenEntries } from './osvRows';
let row = 0; let row = 0;
const e = ( const e = (
@ -18,6 +18,7 @@ const e = (
row_value: value, row_value: value,
group_type: groupType, group_type: groupType,
group_name: null, group_name: null,
data_type: 'BU',
account_code: null, account_code: null,
counterparty_name: null, counterparty_name: null,
counterparty_inn: null, counterparty_inn: null,
@ -32,16 +33,29 @@ const e = (
}); });
describe('flattenEntries', () => { describe('flattenEntries', () => {
it('строки — только аналитика нижнего уровня с субсчётом', () => { it('все строки в порядке файла: счёт и субсчёт — группы, вложенные — с отступом по уровню (AURORA-1702)', () => {
const { rows } = flattenEntries([ const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', { opening_debit: '10' }, { account_code: '62' }), e('62', null, 'ACCOUNT', '62', { opening_debit: '10' }, { account_code: '62' }),
e('62.01', '62', 'ACCOUNT', '62.01', {}, { account_code: '62.01' }), e('62.01', '62', 'ACCOUNT', '62.01', { opening_debit: '10' }, { account_code: '62.01' }),
e('a', '62.01', 'COUNTERPARTY', 'ООО А', { opening_debit: '7' }, { account_code: '62.01', counterparty_inn: '123' }), e('a', '62.01', 'COUNTERPARTY', 'ООО А', { opening_debit: '7' }, { account_code: '62.01', counterparty_inn: '123' }),
e('b', '62.01', 'COUNTERPARTY', 'ООО Б', { opening_debit: '3' }, { account_code: '62.01' }), e('b', '62.01', 'COUNTERPARTY', 'ООО Б', { opening_debit: '3' }, { account_code: '62.01' }),
]); ]);
expect(rows.map((r) => [r.account, r.analytics, r.inn, r.opening_debit])).toEqual([ expect(rows.map((r) => [r.account, r.analytics, r.inn, r.opening_debit, r.depth, r.isGroup])).toEqual([
['62.01', 'ООО А', '123', '7'], ['62', '62', null, '10', 0, true],
['62.01', 'ООО Б', null, '3'], ['62.01', '62.01', null, '10', 1, true],
['62.01', 'ООО А', '123', '7', 2, false],
['62.01', 'ООО Б', null, '3', 2, false],
]);
});
it('группа без сумм всё равно выводится — это заголовок вложенных строк', () => {
const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('a', '62', 'COUNTERPARTY', 'ООО А', { opening_debit: '7' }, { account_code: '62' }),
]);
expect(rows.map((r) => [r.analytics, r.isGroup])).toEqual([
['62', true],
['ООО А', false],
]); ]);
}); });
@ -53,7 +67,12 @@ describe('flattenEntries', () => {
e('a', 'd', 'COUNTERPARTY', 'РБ', {}, { account_code: '62.01' }), e('a', 'd', 'COUNTERPARTY', 'РБ', {}, { account_code: '62.01' }),
]); ]);
expect(hasDepartments).toBe(true); expect(hasDepartments).toBe(true);
expect(rows.map((r) => [r.department, r.analytics])).toEqual([['БП Аренда', 'РБ']]); expect(rows.map((r) => [r.department, r.analytics, r.isGroup])).toEqual([
[null, '62', true],
[null, '62.01', true],
['БП Аренда', null, true],
['БП Аренда', 'РБ', false],
]);
}); });
it('договоры не отдельными строками: остаётся строка контрагента', () => { it('договоры не отдельными строками: остаётся строка контрагента', () => {
@ -63,7 +82,10 @@ describe('flattenEntries', () => {
e('c1', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '60' }, { account_code: '58.03' }), e('c1', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '60' }, { account_code: '58.03' }),
e('c2', 'a', 'CONTRACT', 'Договор 2', { opening_debit: '40' }, { account_code: '58.03' }), e('c2', 'a', 'CONTRACT', 'Договор 2', { opening_debit: '40' }, { account_code: '58.03' }),
]); ]);
expect(rows.map((r) => [r.analytics, r.opening_debit])).toEqual([['РБ ТК', '100']]); expect(rows.map((r) => [r.analytics, r.opening_debit, r.isGroup])).toEqual([
['58.03', null, true],
['РБ ТК', '100', false],
]);
}); });
it('объекты ОС (OTHER) — строки аналитики', () => { it('объекты ОС (OTHER) — строки аналитики', () => {
@ -71,19 +93,20 @@ describe('flattenEntries', () => {
e('01', null, 'ACCOUNT', '01', {}, { account_code: '01' }), e('01', null, 'ACCOUNT', '01', {}, { account_code: '01' }),
e('o', '01', 'OTHER', 'Автопогрузчик', { opening_debit: '5' }, { account_code: '01.01' }), e('o', '01', 'OTHER', 'Автопогрузчик', { opening_debit: '5' }, { account_code: '01.01' }),
]); ]);
expect(rows.map((r) => [r.account, r.analytics])).toEqual([['01.01', 'Автопогрузчик']]); expect(rows.filter((r) => !r.isGroup).map((r) => [r.account, r.analytics])).toEqual([['01.01', 'Автопогрузчик']]);
}); });
it('субсчёт и подразделение без нижних строк — строка с пустой аналитикой; пустые без сумм — нет', () => { it('субсчёт и подразделение без нижних строк — строка без отдельной аналитики; пустые без сумм — нет', () => {
const { rows } = flattenEntries([ const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }), e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('62.21', '62', 'ACCOUNT', '62.21', { closing_credit: '128041.39' }, { account_code: '62.21' }), e('62.21', '62', 'ACCOUNT', '62.21', { closing_credit: '128041.39' }, { account_code: '62.21' }),
e('62.22', '62', 'ACCOUNT', '62.22', {}, { account_code: '62.22' }), e('62.22', '62', 'ACCOUNT', '62.22', {}, { account_code: '62.22' }),
e('d', '62', 'DEPARTMENT', 'Опт', { turnover_debit: '1' }, { account_code: '62' }), e('d', '62', 'DEPARTMENT', 'Опт', { turnover_debit: '1' }, { account_code: '62' }),
]); ]);
expect(rows.map((r) => [r.account, r.department, r.analytics])).toEqual([ expect(rows.map((r) => [r.account, r.department, r.analytics, r.isGroup])).toEqual([
['62.21', null, null], ['62', null, '62', true],
['62', 'Опт', null], ['62.21', null, '62.21', false],
['62', 'Опт', null, false],
]); ]);
}); });
@ -97,14 +120,14 @@ describe('flattenEntries', () => {
expect(total.opening_debit).toBeNull(); expect(total.opening_debit).toBeNull();
}); });
it('субсчета для фильтра — уникальные, в натуральном порядке', () => { it('счета и субсчета для фильтра — уникальные, в натуральном порядке', () => {
const { accounts } = flattenEntries([ const { accounts } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }), e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('b', '62', 'COUNTERPARTY', 'Б', {}, { account_code: '62.10' }), e('b', '62', 'COUNTERPARTY', 'Б', {}, { account_code: '62.10' }),
e('a', '62', 'COUNTERPARTY', 'А', {}, { account_code: '62.02' }), e('a', '62', 'COUNTERPARTY', 'А', {}, { account_code: '62.02' }),
e('c', '62', 'COUNTERPARTY', 'В', {}, { account_code: '62.02' }), e('c', '62', 'COUNTERPARTY', 'В', {}, { account_code: '62.02' }),
]); ]);
expect(accounts).toEqual(['62.02', '62.10']); expect(accounts).toEqual(['62', '62.02', '62.10']);
}); });
it('пустой документ', () => { it('пустой документ', () => {
@ -120,6 +143,72 @@ describe('flattenEntries', () => {
}); });
}); });
describe('flattenEntries: строки-показатели «Кол.», «НУ», «Вал.» (AURORA-1703)', () => {
const ind = (id: string, parent: string, label: string, dataType: EntryOut['data_type'], amount: string) =>
e(id, parent, 'OTHER', label, { opening_debit: amount }, { account_code: '41.01', data_type: dataType });
it('строка БУ остаётся строкой с рублями, показатели под ней не выводятся', () => {
const { rows } = flattenEntries([
e('41', null, 'ACCOUNT', '41.01', { opening_debit: '1000.00' }, { account_code: '41.01' }),
e('n', '41', 'OTHER', 'Мука', { opening_debit: '1000.00' }, { account_code: '41.01', group_name: 'Номенклатура' }),
ind('q', 'n', 'Кол.', 'QUANTITY', '25.000'),
ind('t', 'n', 'НУ', 'NU', '990.00'),
ind('c', 'n', 'Вал.', 'CURRENCY', '11.00'),
ind('p', 'n', 'ПР', null, '10.00'),
]);
expect(rows.map((r) => [r.analytics, r.opening_debit, r.isGroup])).toEqual([
['41.01', '1000.00', true],
['Мука', '1000.00', false],
]);
});
it('показатель под строкой счёта не делает счёт группой без вложенных строк', () => {
const { rows } = flattenEntries([
e('10', null, 'ACCOUNT', '10.01', { opening_debit: '5.00' }, { account_code: '10.01' }),
ind('q', '10', 'Кол.', 'QUANTITY', '3.000'),
]);
expect(rows.map((r) => [r.analytics, r.opening_debit, r.isGroup])).toEqual([['10.01', '5.00', false]]);
});
it('показатели не определяют вид аналитики', () => {
const t = flattenEntries([
e('41', null, 'ACCOUNT', '41.01', {}, { account_code: '41.01' }),
e('n', '41', 'OTHER', 'Мука', { opening_debit: '1' }, { group_name: 'Номенклатура' }),
ind('q', 'n', 'Кол.', 'QUANTITY', '1'),
]);
expect([t.analyticsTitle, t.analyticsHint]).toEqual(['Номенклатура', null]);
});
it('документ целиком в НУ (вид из шапки) — строки обычные, не показатели', () => {
const nu = { data_type: 'NU' as const };
const { rows } = flattenEntries([
e('68', null, 'ACCOUNT', '68', { opening_debit: '5' }, { account_code: '68', ...nu }),
e('a', '68', 'COUNTERPARTY', 'ИФНС', { opening_debit: '5' }, { account_code: '68', ...nu }),
]);
expect(rows.map((r) => r.analytics)).toEqual(['68', 'ИФНС']);
});
it('строка «БУ» под счётом с видом из шапки SUM — обычная строка, не показатель', () => {
const { rows } = flattenEntries([
e('41', null, 'ACCOUNT', '41.01', { opening_debit: '5' }, { account_code: '41.01', data_type: 'SUM' }),
e('n', '41', 'OTHER', 'Мука', { opening_debit: '5' }, { account_code: '41.01', data_type: 'BU' }),
]);
expect(rows.map((r) => r.analytics)).toEqual(['41.01', 'Мука']);
});
it('старые документы без вида строк (data_type null) — дерево как раньше', () => {
const none = { data_type: null };
const { rows } = flattenEntries([
e('60', null, 'ACCOUNT', '60', { opening_debit: '5' }, { account_code: '60', ...none }),
e('o', '60', 'OTHER', 'Прочее', { opening_debit: '5' }, { account_code: '60', ...none }),
]);
expect(rows.map((r) => [r.analytics, r.isGroup])).toEqual([
['60', true],
['Прочее', false],
]);
});
});
describe('flattenEntries: вид аналитики', () => { describe('flattenEntries: вид аналитики', () => {
const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' }); const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' });
const cp = (id: string) => e(id, '60', 'COUNTERPARTY', `ООО ${id}`, { opening_debit: '1' }, { group_name: 'Контрагенты' }); const cp = (id: string) => e(id, '60', 'COUNTERPARTY', `ООО ${id}`, { opening_debit: '1' }, { group_name: 'Контрагенты' });
@ -165,3 +254,33 @@ describe('flattenEntries: вид аналитики', () => {
expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Аналитика', null, false]); expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Аналитика', null, false]);
}); });
}); });
describe('filterRows', () => {
const { rows } = flattenEntries([
e('62', null, 'ACCOUNT', '62', { opening_debit: '10' }, { account_code: '62' }),
e('62.01', '62', 'ACCOUNT', '62.01', { opening_debit: '7' }, { account_code: '62.01' }),
e('a', '62.01', 'COUNTERPARTY', 'ООО Ромашка', { opening_debit: '7' }, { account_code: '62.01', counterparty_inn: '7701' }),
e('62.02', '62', 'ACCOUNT', '62.02', { opening_debit: '3' }, { account_code: '62.02' }),
e('b', '62.02', 'COUNTERPARTY', 'ООО Лютик', { opening_debit: '3' }, { account_code: '62.02' }),
e('620', null, 'ACCOUNT', '620', { opening_debit: '1' }, { account_code: '620' }),
]);
const keys = (list: typeof rows) => list.map((r) => r.key);
it('без фильтров — все строки', () => {
expect(keys(filterRows(rows, '', undefined))).toEqual(['62', '62.01', 'a', '62.02', 'b', '620']);
});
it('субсчёт — его строка и вложенные', () => {
expect(keys(filterRows(rows, '', '62.01'))).toEqual(['62.01', 'a']);
});
it('счёт — он сам и все субсчета, но не счёт с тем же началом кода', () => {
expect(keys(filterRows(rows, '', '62'))).toEqual(['62', '62.01', 'a', '62.02', 'b']);
});
it('поиск скрывает строки-группы и ищет по аналитике без учёта регистра и по ИНН', () => {
expect(keys(filterRows(rows, 'ромаш', undefined))).toEqual(['a']);
expect(keys(filterRows(rows, ' 7701 ', undefined))).toEqual(['a']);
expect(keys(filterRows(rows, '62', undefined))).toEqual(['620']);
});
});

View File

@ -12,20 +12,24 @@ export const AMOUNT_KEYS = [
export type AmountKey = (typeof AMOUNT_KEYS)[number]; export type AmountKey = (typeof AMOUNT_KEYS)[number];
export type Amounts = Record<AmountKey, string | null>; export type Amounts = Record<AmountKey, string | null>;
/** Строка плоской таблицы «Просмотр ОСВ»: аналитика нижнего уровня (контрагент, объект ОС). */ /** Строка таблицы «Просмотр ОСВ» в порядке файла: счёт, субсчёт, подразделение или аналитика (контрагент, объект ОС). */
export type OsvRow = Amounts & { export type OsvRow = Amounts & {
key: string; key: string;
account: string; account: string;
department: string | null; department: string | null;
analytics: string | null; analytics: string | null;
inn: string | null; inn: string | null;
/** Глубина вложенности (0 — корень) — отступ в колонке аналитики, как в 1С. */
depth: number;
/** У строки есть вложенные строки (счёт, субсчёт, подразделение с аналитикой) — выводится жирным. */
isGroup: boolean;
}; };
export type OsvTable = { export type OsvTable = {
rows: OsvRow[]; rows: OsvRow[];
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */ /** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
total: Amounts; total: Amounts;
/** Субсчета строк для фильтра, в натуральном порядке. */ /** Счета и субсчета строк для фильтра, в натуральном порядке. */
accounts: string[]; accounts: string[];
/** В файле есть уровень «Подразделение» — показать колонку. */ /** В файле есть уровень «Подразделение» — показать колонку. */
hasDepartments: boolean; hasDepartments: boolean;
@ -65,42 +69,55 @@ export function analyticsHeading(leaves: EntryOut[]): Pick<OsvTable, 'analyticsT
} }
const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']); const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']);
/** Виды сумм в рублях: такие строки — обычные строки таблицы, не показатели. */
const RUBLE_TYPES = new Set(['BU', 'SUM']);
const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined); const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined);
/** Строки ОСВ (порядок API — по row_number) → плоская таблица. Договоры (CONTRACT) отдельными строками не идут: их сумма /** Строка-показатель (§1.3 правил предметной области: «Кол.», «НУ», «Вал.», «ПР»/«ВР» колонки «Показатели»): не уровень
* уже в строке контрагента, как в сводной ОСВ (§3). Субсчёт или подразделение без нижних строк, но с суммами — строка с * группировки, а вид сумм родителя — OTHER, вид не рублёвый и не совпадает с видом родителя. Совпадение с родителем —
* пустой аналитикой. Битые ссылки на родителя и циклы не роняют разбор: предки ищутся с ограничением шагов. */ * документ целиком в одном виде (НУ из шапки) или старый документ без вида строк. */
const isIndicator = (entry: EntryOut, parent: EntryOut | undefined) =>
parent !== undefined && entry.group_type === 'OTHER' && !RUBLE_TYPES.has(entry.data_type ?? '') && entry.data_type !== parent.data_type;
/** Строки ОСВ (порядок API — по row_number) → таблица в порядке файла, как в 1С: счёт и субсчета — строки-группы над
* вложенными строками. Договоры (CONTRACT) отдельными строками не идут: их сумма уже в строке контрагента, как в сводной
* ОСВ (§3). Строки-показатели (`isIndicator`) в рублёвую таблицу не попадают и родителя группой не делают. Субсчёт или
* подразделение без вложенных строк выводится, только если у него есть суммы. Битые ссылки на родителя и циклы не
* роняют разбор: предки ищутся с ограничением шагов. */
export function flattenEntries(entries: EntryOut[]): OsvTable { export function flattenEntries(entries: EntryOut[]): OsvTable {
const byId = new Map(entries.map((entry) => [entry.id, entry])); const byId = new Map(entries.map((entry) => [entry.id, entry]));
const withChildren = new Set( const parentOf = (entry: EntryOut) => (entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined);
entries.filter((entry) => entry.group_type !== 'CONTRACT' && entry.parent_entry_id).map((entry) => entry.parent_entry_id as string), const shown = entries.filter((entry) => entry.group_type !== 'CONTRACT' && !isIndicator(entry, parentOf(entry)));
); const withChildren = new Set(shown.map((entry) => entry.parent_entry_id).filter((id): id is string => id !== null));
const ancestors = (entry: EntryOut): EntryOut[] => { const ancestors = (entry: EntryOut): EntryOut[] => {
const chain: EntryOut[] = []; const chain: EntryOut[] = [];
let current = entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined; let current = parentOf(entry);
for (let steps = 0; current && current.id !== entry.id && steps < entries.length; steps++) { for (let steps = 0; current && current.id !== entry.id && steps < entries.length; steps++) {
chain.push(current); chain.push(current);
current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined; current = parentOf(current);
} }
return chain; return chain;
}; };
const rows: OsvRow[] = []; const rows: OsvRow[] = [];
const leaves: EntryOut[] = []; const leaves: EntryOut[] = [];
for (const entry of entries) { for (const entry of shown) {
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue; const isGroup = withChildren.has(entry.id);
const structural = STRUCTURE.has(entry.group_type ?? ''); const structural = STRUCTURE.has(entry.group_type ?? '');
if (structural && !hasAmount(entry)) continue; if (structural && !isGroup && !hasAmount(entry)) continue;
if (!structural) leaves.push(entry); if (!structural && !isGroup) leaves.push(entry);
const chain = [entry, ...ancestors(entry)]; const parents = ancestors(entry);
const chain = [entry, ...parents];
const department = chain.find((item) => item.group_type === 'DEPARTMENT'); const department = chain.find((item) => item.group_type === 'DEPARTMENT');
rows.push({ rows.push({
key: entry.id, key: entry.id,
account: chain.find((item) => item.account_code)?.account_code ?? '', account: chain.find((item) => item.account_code)?.account_code ?? '',
department: department?.row_value ?? null, department: department?.row_value ?? null,
analytics: structural ? null : (entry.row_value ?? entry.counterparty_name), analytics: entry.group_type === 'DEPARTMENT' ? null : (entry.row_value ?? entry.counterparty_name),
inn: entry.counterparty_inn, inn: entry.counterparty_inn,
depth: parents.length,
isGroup,
...(Object.fromEntries(AMOUNT_KEYS.map((key) => [key, entry[key]])) as Amounts), ...(Object.fromEntries(AMOUNT_KEYS.map((key) => [key, entry[key]])) as Amounts),
}); });
} }
@ -110,3 +127,14 @@ export function flattenEntries(entries: EntryOut[]): OsvTable {
const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true })); const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) }; return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) };
} }
/** Фильтр по счёту — счёт и все его субсчета («62» → 62, 62.01, 62.02); поиск по аналитике (и ИНН) без учёта регистра.
* При поиске строки-группы скрываются: остаются только найденные строки. На клиенте: строки документа загружены целиком. */
export function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
const q = query.trim().toLowerCase();
return rows.filter(
(row) =>
(!account || row.account === account || row.account.startsWith(`${account}.`)) &&
(!q || (!row.isGroup && ((row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)))),
);
}