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`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `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.
- `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh` (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`).
- OpenSpec: `openspec/` здесь; сквозные изменения — change в `../docs`.

View File

@ -360,7 +360,7 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear
## Администрирование и экран /admin
Бэкенд — `/api/v1/admin/*`, все маршруты требуют `USERS_ADMIN` (без права — 403 до любых проверок тела и существования
объектов). Фронтенд — страница `/admin` («Пользователи и роли» в боковом меню; видна только с `USERS_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 up # если инфраструктура ещё не поднята
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`; форма вызывает
@ -870,7 +870,14 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
`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; требования — в существующих спецификациях
`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
(`ensure_version_editable`, §10)
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)
openspec/ спецификации и изменения (OpenSpec)
docs/superpowers/ планы реализации

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -497,7 +497,7 @@ class IntercompanyMatrixService:
raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон")
debit = _select_accounts(debit_ids, debit_excluded, "D", 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

View File

@ -28,7 +28,8 @@ from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
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.osv_mapping import build_entries, document_fields
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 - защита от перепутанных аргументов в тестах
payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)}
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)]
chart = {
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 datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from src.contract.result import RecognitionResult
from src.contract.result_v2 import RecognitionResultV2
from src.models.directories import LegalEntity
from src.models.enums import RecognitionStatus
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":
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`)
как `RecognitionResult` контракта v1.2.
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) как файл результата: модель — по MAJOR
`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"))
version = data.get("schema_version", "1.2")
payload: dict[str, object] = {
"schema_version": "1.2",
"schema_version": version,
"job_id": str(job_id),
"file_id": str(file_id),
"service": {"name": "scan-gateway", "version": "golden"},
"service": _golden_service({"name": "scan-gateway"}, version),
"status": data["status"],
"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"]
if data.get("metrics") is not None:
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":
"""Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса."""
from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Golden-снимок парсера как файл результата (модель — по MAJOR `schema_version`): подставляет `job_id`/`file_id`
и версию сервиса."""
from src.contract.versions import parse_result # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
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"]}
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 - все входы именованные
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)
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
issues = await _count(seeded, DocumentIssue) # предупреждения сервиса из снимка 2.0
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:
await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED))
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")
assert listed.status_code == OK, listed.text
assert listed.json()["is_version_fixed"] is False
assert listed.json()["version_no"] == rule.version_no
assert listed.json()["as_of"] is not None
assert listed.json()["version_no"] is None
assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
response = await client.get(url)
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.json()["is_version_fixed"] is True
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 = await client.get(missing_url)
assert missing.status_code == NOT_FOUND

View File

@ -2,6 +2,7 @@
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
import datetime
import functools
import json
import uuid
from decimal import Decimal
@ -51,10 +52,14 @@ from tests.factories import (
make_stored_file,
)
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]
# формы — из снимков, сведённых к 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"
RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал"

View File

@ -33,9 +33,7 @@ def _rule() -> SimpleNamespace:
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True),
],
)
def test_rule_is_valid_for_version(
rule: SimpleNamespace | None, version_no: int | None, expected: bool
) -> None:
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None:
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.version_no == rule.version_no
assert result.as_of is None
assert [item.id for item in result.rules] == [rule.id]
assert result.rules[0].company_1.accounts == [account_id]
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.version_no is None
assert result.as_of is None
assert [item.id for item in result.rules] == [rule.id]
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.version_no is None
assert result.as_of is None
assert result.rules == []
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
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()
@ -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.service.name == "scan-gateway"
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.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
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())
assert result.status == "error" and result.documents == []
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:

View File

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

View File

@ -1,6 +1,7 @@
"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и
прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures."""
import functools
import json
import uuid
from datetime import UTC, datetime
@ -35,10 +36,14 @@ from src.models.statements import (
from src.repositories.counterparties import CounterpartyIndex
from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file
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]
# модуль проверяет маппинг форм `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"
NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic"
@ -312,9 +317,9 @@ async def test_duplicate_column_index_does_not_fail_mapping(
)
)
).all()
first = columns[0]
assert sorted(i for i, _ in stored) == sorted({c["column_index"] for c in columns})
assert (first["column_index"], first["label"]) in stored and (first["column_index"], "дубль") not in stored
first = columns[0] # снимок сведён к 1.2: колонки пронумерованы с 0, повтор номера первой колонки — тоже 0
assert sorted(i for i, _ in stored) == list(range(len({c["column_index"] for c in columns})))
assert (0, first["label"]) in stored and (0, "дубль") not in stored
file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None and file.status is FileStatus.PROCESSED
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
await seeded.flush()
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(
seeded,

View File

@ -527,3 +527,23 @@ async def test_osv_v2_sheet_needs_check_blocks_recognized(seeded: AsyncSession)
osv["status"] = "needs_check"
(document,) = await _map(seeded, entity, [osv])
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`, архитектура —
`ARCHITECTURE.md`, соглашения репозитория — `CLAUDE.md`.
SPA кредитного аналитика поверх API `arm-core` (`/api/v1`). Контекст системы — `../../CLAUDE.md`, ядро — `../README.md`,
архитектура фронта — `ARCHITECTURE.md`, соглашения — `CLAUDE.md`. С 21.10.2026 фронт ведётся здесь (PR в arm-core
`main`); прежний репозиторий `RSHB/arm-web` заморожен.
## Запуск
1. `make install`.
2. Стенд arm-core — по `../docs/dev/qa-guide.md` §4 (ядро на `localhost:8000`, `DEBUG=true`, `make seed-dev`); для
1. `npm install` (из `web/`).
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).
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` проксируются на
`ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен.
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 (`.prettierrc.json`) — единственный форматтер: табы, ширина 140, одинарные кавычки (в CSS — двойные),
`>` тега 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`,
`console`, зависимости хуков) — нет.
- В редакторе: расширения 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`) → члены ГСК отмечены → «Создать» → открывается страница группы с новой версией.
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_DIR`, по умолчанию `../../fixtures` относительно репозитория; в конце печатает сводку).
3. ББ/ОФР: открыть уже загруженный ББ Счастливочной (или загрузить `../fixtures/ocr/aep_6.pdf` РБ) → скан слева, форма справа,
`../../fixtures/osv/{rb,schastl}/2024-Q{1,2,3}/*`; для новой версии быстро — `../../docs/scripts/demo-prepare.sh <version_id> [email]`
(каталог фикстур — `FIXTURES_DIR`, по умолчанию `fixtures` workspace; в конце печатает сводку).
3. ББ/ОФР: открыть уже загруженный ББ Счастливочной (или загрузить `../../fixtures/ocr/aep_6.pdf` РБ) → скан слева, форма справа,
исправить ячейку. ИНН и дата отчёта в aep_6 не распознаются: в шапке ввести дату строго `2024-09-30` (ГГГГ-ММ-ДД) и
нажать «Сохранить», иначе «Подтвердить данные» даст 409 `PERIOD_REQUIRED`. Принять нарушения с комментарием (если есть), подтвердить.
4. «К отчётности» → сводная ОСВ; фильтр «Счастливочная» + «бисквит» → строка `60.01` (сальдо нач. Кт 4 454 151.93,
@ -62,13 +63,11 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
(«Показать все»); в режиме правки можно назначить код строке, пометить её расшифровкой и подтвердить значение, код или дату
колонки — всё уходит одним `PATCH`; кнопка «Подтвердить данные» заблокирована, пока есть строки без кода (ссылка
«Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа.
Карта модулей — в `CLAUDE.md`, раздел «Архитектура».
Карта модулей — `ARCHITECTURE.md`.
## Ограничения прототипа
## Ограничения
- Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов,
смены периметра и ручных переходов статусов.
- Нет правки строк ОСВ (в API нет), картинок страниц и координат ячеек в скане (в API нет).
- Нет генерации типов из OpenAPI и автотестов компонентов (vitest — только чистые функции).
- Нет сборки под контур и переноса в `arm-core/web` — отдельной задачей после демо.
- Изменений arm-core / scan-gateway нет: найденные пробелы — отдельные issue в тех репозиториях.

View File

@ -127,6 +127,8 @@ export interface IssueCountsOut {
}
/** Уровень строки ОСВ (DBML `osv_group_type`). */
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 {
id: string;
@ -136,6 +138,7 @@ export interface EntryOut {
row_value: string | null;
group_type: OsvGroupType | null;
group_name: string | null;
data_type: OsvDataType | null;
account_code: string | null;
counterparty_name: string | null;
counterparty_inn: string | null;

View File

@ -10,11 +10,15 @@ import { IssuesPanel } from './IssuesPanel';
import { confirmButtonStyle } from './ReviewCard';
import { expectedActualText, failureKey } from './checkText';
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';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160;
/** Отступ одного уровня вложенности в колонке аналитики, px. */
const INDENT = 16;
/** Строки-группы (счёт, субсчёт, подразделение с вложенными строками) — жирным, как промежуточные итоги в 1С. */
const groupRowStyle = { fontWeight: 600 };
const money = (key: AmountKey, title: string) => ({
key,
@ -48,7 +52,8 @@ function buildColumns(options: ColumnsOptions): ColumnsType<OsvRow> {
title: analyticsHint ? <Tooltip title={analyticsHint}>{analyticsTitle}</Tooltip> : analyticsTitle,
dataIndex: 'analytics',
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 }] : []),
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 {
code: string | undefined;
message: string;
@ -164,7 +160,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
/>
<Select
allowClear
placeholder='Субсчёт'
placeholder='Счёт / субсчёт'
style={{ width: 160 }}
value={account}
onChange={setAccount}
@ -199,6 +195,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
bordered
size='small'
rowKey='key'
onRow={(row) => ({ style: row.isGroup ? groupRowStyle : undefined })}
loading={loading}
columns={columns}
dataSource={rows}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { EntryOut, OsvGroupType } from '../../types/api';
import { flattenEntries } from './osvRows';
import { filterRows, flattenEntries } from './osvRows';
let row = 0;
const e = (
@ -18,6 +18,7 @@ const e = (
row_value: value,
group_type: groupType,
group_name: null,
data_type: 'BU',
account_code: null,
counterparty_name: null,
counterparty_inn: null,
@ -32,16 +33,29 @@ const e = (
});
describe('flattenEntries', () => {
it('строки — только аналитика нижнего уровня с субсчётом', () => {
it('все строки в порядке файла: счёт и субсчёт — группы, вложенные — с отступом по уровню (AURORA-1702)', () => {
const { rows } = flattenEntries([
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('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([
['62.01', 'ООО А', '123', '7'],
['62.01', 'ООО Б', null, '3'],
expect(rows.map((r) => [r.account, r.analytics, r.inn, r.opening_debit, r.depth, r.isGroup])).toEqual([
['62', '62', null, '10', 0, true],
['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' }),
]);
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('договоры не отдельными строками: остаётся строка контрагента', () => {
@ -63,7 +82,10 @@ describe('flattenEntries', () => {
e('c1', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '60' }, { 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) — строки аналитики', () => {
@ -71,19 +93,20 @@ describe('flattenEntries', () => {
e('01', null, 'ACCOUNT', '01', {}, { account_code: '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([
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.22', '62', 'ACCOUNT', '62.22', {}, { account_code: '62.22' }),
e('d', '62', 'DEPARTMENT', 'Опт', { turnover_debit: '1' }, { account_code: '62' }),
]);
expect(rows.map((r) => [r.account, r.department, r.analytics])).toEqual([
['62.21', null, null],
['62', 'Опт', null],
expect(rows.map((r) => [r.account, r.department, r.analytics, r.isGroup])).toEqual([
['62', null, '62', true],
['62.21', null, '62.21', false],
['62', 'Опт', null, false],
]);
});
@ -97,14 +120,14 @@ describe('flattenEntries', () => {
expect(total.opening_debit).toBeNull();
});
it('субсчета для фильтра — уникальные, в натуральном порядке', () => {
it('счета и субсчета для фильтра — уникальные, в натуральном порядке', () => {
const { accounts } = flattenEntries([
e('62', null, 'ACCOUNT', '62', {}, { account_code: '62' }),
e('b', '62', 'COUNTERPARTY', 'Б', {}, { account_code: '62.10' }),
e('a', '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('пустой документ', () => {
@ -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: вид аналитики', () => {
const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' });
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]);
});
});
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 Amounts = Record<AmountKey, string | null>;
/** Строка плоской таблицы «Просмотр ОСВ»: аналитика нижнего уровня (контрагент, объект ОС). */
/** Строка таблицы «Просмотр ОСВ» в порядке файла: счёт, субсчёт, подразделение или аналитика (контрагент, объект ОС). */
export type OsvRow = Amounts & {
key: string;
account: string;
department: string | null;
analytics: string | null;
inn: string | null;
/** Глубина вложенности (0 — корень) — отступ в колонке аналитики, как в 1С. */
depth: number;
/** У строки есть вложенные строки (счёт, субсчёт, подразделение с аналитикой) — выводится жирным. */
isGroup: boolean;
};
export type OsvTable = {
rows: OsvRow[];
/** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */
total: Amounts;
/** Субсчета строк для фильтра, в натуральном порядке. */
/** Счета и субсчета строк для фильтра, в натуральном порядке. */
accounts: string[];
/** В файле есть уровень «Подразделение» — показать колонку. */
hasDepartments: boolean;
@ -65,42 +69,55 @@ export function analyticsHeading(leaves: EntryOut[]): Pick<OsvTable, 'analyticsT
}
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);
/** Строки ОСВ (порядок API — по row_number) → плоская таблица. Договоры (CONTRACT) отдельными строками не идут: их сумма
* уже в строке контрагента, как в сводной ОСВ (§3). Субсчёт или подразделение без нижних строк, но с суммами — строка с
* пустой аналитикой. Битые ссылки на родителя и циклы не роняют разбор: предки ищутся с ограничением шагов. */
/** Строка-показатель (§1.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 {
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const withChildren = new Set(
entries.filter((entry) => entry.group_type !== 'CONTRACT' && entry.parent_entry_id).map((entry) => entry.parent_entry_id as string),
);
const parentOf = (entry: EntryOut) => (entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined);
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 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++) {
chain.push(current);
current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined;
current = parentOf(current);
}
return chain;
};
const rows: OsvRow[] = [];
const leaves: EntryOut[] = [];
for (const entry of entries) {
if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue;
for (const entry of shown) {
const isGroup = withChildren.has(entry.id);
const structural = STRUCTURE.has(entry.group_type ?? '');
if (structural && !hasAmount(entry)) continue;
if (!structural) leaves.push(entry);
const chain = [entry, ...ancestors(entry)];
if (structural && !isGroup && !hasAmount(entry)) continue;
if (!structural && !isGroup) leaves.push(entry);
const parents = ancestors(entry);
const chain = [entry, ...parents];
const department = chain.find((item) => item.group_type === 'DEPARTMENT');
rows.push({
key: entry.id,
account: chain.find((item) => item.account_code)?.account_code ?? '',
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,
depth: parents.length,
isGroup,
...(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 }));
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)))),
);
}