Compare commits

...

5 Commits

Author SHA1 Message Date
Andrey Epifancev
248f8b0c74 openspec(#116): спека по ревью K-1…K-25 и решениям владельца Р-20…Р-22; решения S-26…S-52
- design.md: Р-20 (законные повторы кода в перечне, синонимы в словаре имён), Р-21 (мягкое соотношение, сошедшееся
  точно, подтверждает неуверенное значение), Р-22 (S-1…S-25 приняты с оговорками; отмечены утверждённые владельцем);
  решения при доработке S-26…S-52 с ценой ошибки
- statement-normalization: отдельное требование sync-statements (пути, VERSION, коды 0 / 10 / 20 / 2), словарь
  единиц и ОКЕИ, ref Excel с кавычками, нумерация с двойниками и пропуском, номер колонки, содержимое строки, столбец
  кодов у DISCLOSURE, упрощённый 2400 мягкий, Р-20 / Р-21 со сценариями, обе редакции перечня, интерфейс функции
  статуса, TABLE_PARSE_ERROR / SHEET_OTHER уровня файла
- arm-core: признак строки правки row_index IS NULL, header_source, формы без checks (1.x и миграция) — §7 ядром в
  document_issue, codes / column_index, RESULT_STATUS_INCONSISTENT, синонимы и «итого по разделу», PATCH
  organization_name / confirmed: false / подтверждение даты, LINE_CODE_TAKEN, акцепт и подтверждения, LINES_UNRESOLVED
  с текстом, «того же периода», откат миграции
- recognition-contract: MODIFIED «Копия контракта … идентична» — сверка по MAJOR копии, *.v1.schema.json на окне;
  1.x — MAPPING_WARNING / LEGACY_WARNING; okei, column_index, пустые массивы DISCLOSURE, статус ОСВ одной формулировкой
- quality-checks (новая дельта): make check охватывает копию arm_statements, make check-full — statements-check
- tabular-recognition-service: манифест statements_manifest.yaml — failed_relations страниц 2 баланса (K-25)
- scan-gateway, arm-web: TABLE_PARSE_ERROR файла; подсказки шапки, снятие подтверждений, LINE_CODE_TAKEN
- proposal: шаг 0 выкладки, перечни кодов, ожидания на реальных сканах, Impact

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 02:17:36 +04:00
Andrey Epifancev
2a37a70189 docs(#116): *_original_value шапки как прежде, архитектура — механика копии arm_statements
- DBML и §9.5 п. 2: inn_original_value = inn, report_date_original_value = report_date_text, units_original_value =
  units_text; сырой текст и уверенность всех полей — header_source (K-3)
- 03-architecture: путь копии, VERSION, коды sync-statements --check, перечень кодов в contracts/ (K-9, K-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 02:17:36 +04:00
Andrey Epifancev
2b094ffe0b docs(#116): §7, §9.3, §9.5, §11 по ревью спеки; OQ-148 закрыт, OQ-168; гайд фронта и checks.md
- §7: мягкое соотношение, сошедшееся точно, подтверждает неуверенные значения (Р-21); ОФР упрощённого набора
  2400 — мягкое (Р-22 / S-5); form_variant = null — полный набор; 1.x и мигрированные формы — §7 копией
  arm_statements один раз в document_issue, экран и подтверждение читают только его (K-2)
- §9.3: страховка согласованности document.status = done (K-24), «того же периода» — год и квартал (K-20),
  1.x после правки — общим кодом (K-21)
- §9.5: признак строки правки — row_index IS NULL (K-1); header_source, codes/column_index document_issue (K-3, K-13);
  LINES_UNRESOLVED — сумма или текст (K-16); PATCH: organization_name (K-12), confirmed: false, line_status только
  detail, обратного detail нет, дата колонки обязательна, акцепт сбрасывает только правка данных, подтверждение даты
  отчёта тем же значением (K-17, K-23); 409 LINE_EXISTS → LINE_CODE_TAKEN (K-18)
- §11: TABLE_PARSE_ERROR и SHEET_OTHER — уровня файла (K-7); столбец кодов ищется всегда, DISCLOSURE с кодами ББ/ОФР —
  TYPE_CONFLICT (K-10); номер колонки (K-6); содержимое строки (K-14); нумерация с двойниками и одним пропуском (S-13);
  ref Excel с кавычками (S-15); единицы по словарю и ОКЕИ (K-22); перечень кодов — данные, законные повторы (K-5, Р-20),
  обе редакции всегда; синонимы и «итого по разделу» в сверке имён (S-4); интерфейс функции статуса (K-14); ожидание на
  реальных сканах (K-4); бланк 66н с относительными подписями (K-17)
- 07-open-questions: OQ-148 закрыт (Р-22 / S-7), OQ-166 уточнён, OQ-168 — коды перечня без образца
- dev/checks.md: копия arm_statements, sync-statements (коды 0 / 10 / 20 / 2), statements-check в check-full,
  проверки docs (K-9); frontend-api-guide §5.6.1 по новым правилам

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 02:09:04 +04:00
Andrey Epifancev
a8a4d90c83 model(#116): DBML v5.19 — признак строки правки, сырой текст шапки, codes и column_index у document_issue (K-1, K-3, K-12, K-13, K-19, S-19)
- financial_statement_line: «создана правкой» ⇔ row_index IS NULL; line_code_original_value — код из результата,
  правками не меняется (K-1)
- financial_statement_meta: header_source (jsonb — <поле>_text и <поле>_confidence всех полей шапки),
  organization_name_original_value, organization_name_modified (K-3, K-12)
- document_issue: codes (коды нарушения §7), column_index (столбец вне колонок формы); code у relation / soft —
  RELATION_FAILED / RELATION_SOFT; line_ids очищаются при удалении шаблонной строки (K-6, K-13, S-19)
- заполнение при миграции: row_index = NULL у строк правки, document_issue — §7 копией arm_statements (K-2);
  откат — только на пустой или совместимой с v5.18 БД (K-19)
- entities-v5.19, диаграмма part06 перерисована

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 02:04:13 +04:00
Andrey Epifancev
e33a4a45f9 contracts(#116): окно перехода 1.x, перечни кодов форм, okei, смысл column_index и ref (K-5, K-6, K-8, K-22, S-15)
- recognition-result.v1.schema.json и recognition-result-message.v1.schema.json — байтовые копии схем 1.2 / 1.0
  (до 9b1a4cc), примеры 1.x — examples/v1/; живут до удаления маппера 1.x ядра (K-8)
- statement-code-orders.yaml — упорядоченные перечни кодов ББ/ОФР по редакциям 66n / fsbu4 и вариантам
  full / simplified, с законными повторами (Р-20: 1220, 1520, 2410 у fsbu4; раздел III НКО у 66n) и кодами
  печатных форм 1С из fixtures (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930) (K-5)
- validate_contracts.py: схемы v1 и examples/v1/, проверка формы перечня кодов; make venv ставит PyYAML
- v2.0: header.okei (+ _text, _confidence) и okei в headerField (K-22); columns[].column_index — столбец сетки
  кандидата (K-6); line_refs — только строки lines[]; ref Excel — имя листа в кавычках по правилу Excel (S-15);
  статус ОСВ — по контрольным суммам §1.4 листа (S-22); примеры приведены к этим правилам

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 02:02:41 +04:00
35 changed files with 2707 additions and 1006 deletions

View File

@ -1,7 +1,7 @@
# Команды проверки репозитория arm-ka-docs. Запуск из корня репозитория.
# Сервисных целей (lint/test/up...) здесь нет: описание общих целей сервисов — dev/checks.md.
VENV := .venv
# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должен быть jsonschema)
# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должны быть jsonschema и PyYAML)
PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3)
CHANGE ?= slice-0-skeleton
# Текущая версия модели данных — по ней рисуются диаграммы (make diagrams)
@ -9,12 +9,12 @@ DBML ?= model/data-model-v5.19.dbml
.PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-web sync-web check diagrams
# Единственная зависимость скриптов проверки контракта — jsonschema
# Зависимости скриптов проверки контракта — jsonschema и PyYAML (перечни кодов форм contracts/statement-code-orders.yaml)
venv:
uv venv --python 3.11 --allow-existing $(VENV)
uv pip install --python $(VENV)/bin/python jsonschema
uv pip install --python $(VENV)/bin/python jsonschema pyyaml
# Примеры contracts/examples/ против трёх схем JSON Schema 2020-12
# Примеры contracts/examples/ (и examples/v1/ окна перехода) против схем JSON Schema 2020-12 + перечни кодов форм
validate-contracts:
$(PY) scripts/validate_contracts.py

View File

@ -169,8 +169,10 @@ advisory-lock по `(ЮЛ, sha256)` (двойной клик не проскак
Шаги 2–8 нормализации форм (раскладка, статусы строк, значения, код и наименование, сборка документа, проверки §7,
`needs_review`, статус и предупреждения) и их словари живут в отдельном репозитории `arm-statements` пакетом
`arm_statements/` (Р-1: платформа не собирает Python-пакеты). В tabular-parser, scan-gateway и arm-core пакет лежит
**копией** с тестами, импортируется по имени `arm_statements`; копию раскладывает и проверяет `sync-statements` в docs
(`--check`, код 20 при прямой правке; вызывается из `make check-full`, не из `make check`). Сервисы держат только адаптер
**копией** (`api/arm_statements/`, маркер версии — `api/arm_statements/VERSION`) с тестами, импортируется по имени
`arm_statements`; копию раскладывает и проверяет `sync-statements` в docs (`--check`: 0 — синхронно, 10 — отстала, 20 —
прямая правка, 2 — нет checkout; вызывается из `make check-full`, не из `make check`; механика — `dev/checks.md`).
Упорядоченные перечни кодов форм — данные `contracts/statement-code-orders.yaml`, в пакете — их байтовая копия. Сервисы держат только адаптер
шага 1 (json ИС / лист Excel → `FormCandidate`); ядро считает тем же кодом после правок аналитика и не держит своих
копий словаря §7. Словарь «код → наименование» — только в ядре. Правила — `05-domain-rules.md` §11; маркер версии
копии — `service.statements_version` результата → `recognition_job.statements_version`. Порядок выкладки — «сначала

View File

@ -521,7 +521,9 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou
`1700 = 1300 + 1400 + 1500`, `1600 = 1700`; ОФР — `2100`, `2200`, `2300`. **Мягкие** (`info`, `ASSUMPTION(OQ-12)`): `2400 =
2300 − 2410 + 2420` и варианты ФСБУ 4/2023 (`2400 = 2300 + 2410 + 2460`, `2410 = 2411 + 2412`), `2500 = 2400 + 2510 +
2520 + 2530`, «сумма пятизначных подстрок = родитель» (по колонке — только если хотя бы одна подстрока родителя в ней
непуста: `amount` не `null`, ноль — значение; AURORA-1527).
непуста: `amount` не `null`, ноль — значение; AURORA-1527). Мягкое соотношение на статус и подсветку не влияет, но
**сошедшееся точно** (расхождение в пределах допуска ±1, без «округления») подтверждает неуверенные значения своих
кодов (Р-21, §11.9): так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки.
- **Значение кода** в колонке — сумма по всем строкам `ok` с этим кодом (повтор кода — законный случай, Р-4); строки
`detail`, `unreadable`, `missing` в соотношения не входят; нарушение относится ко всем строкам его кодов.
`duplicate_line_codes` не выдаётся.
@ -543,8 +545,10 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou
1200 (ББ) и 2100 / 2200 / 2300 (ОФР), свои равенства по бланку упрощённой формы (приказ 66н, прил. 5;
`ASSUMPTION` до первого образца в fixtures): ББ — `1600 = 1150 + 1170 + 1210 + 1230 + 1250` (жёсткое), `1700 = 1300 +
1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550` (жёсткое), `1600 = 1700` (жёсткое); ОФР — `2400 = 2110 − 2120 − 2330 +
2340 − 2350 − 2410` (жёсткое, `abs_terms` те же). Оба набора — в одном словаре `statement_checks.yaml`. Без
упрощённого набора полный дал бы нарушения «пустой цели» на каждой упрощённой форме.
2340 − 2350 − 2410` (**мягкое**, как `2400` полного набора: знак 2410 не определён, OQ-12; `abs_terms` те же; решение
владельца 18.10.2026, Р-22 / S-5). Оба набора — в одном словаре `statement_checks.yaml`. Без упрощённого набора полный
дал бы нарушения «пустой цели» на каждой упрощённой форме. `form_variant = null` (результат 1.x, документ до v5.19) —
полный набор.
- **Родитель пятизначной подстроки** — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код
из первых четырёх цифр (12310 под 1230); то же правило ядро отдаёт экрану как `parent_code`.
- **Межформенная** `1370(отчётная дата) − 1370(начало года) = 2400` сервисами не считается — только ядром (ниже).
@ -552,7 +556,11 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou
**Ядро.** При загрузке результата v2.0 ядро §7 **не пересчитывает**: `checks`, `needs_review` и статус документа
берутся из результата (§9.3, §9.5 п. 2). После правки аналитика (`PATCH …/statement`) ядро пересчитывает §7 копией
`arm_statements` по текущим суммам (`financial_statement_value.amount` после правок) — с теми же правилами, что в
сервисах; при приёме результата 1.x (окно выкладки) — прежним правилом D-6. Межформенная `1370(отчётная дата) − 1370(начало
сервисах. Результат 1.x (окно выкладки) и формы, существовавшие до миграции v5.19, `checks` не несут: ядро **один раз**
считает §7 копией `arm_statements` (`form_variant = null` — полный набор) — при приёме 1.x и в миграции — и пишет
нарушения в `document_issue` (`source = core`, `relation` / `soft`); условие D-6 «проверки сошлись» для 1.x берётся из
этого расчёта. Экран (`GET …/statement`) и подтверждение (`CHECKS_NOT_PASSED`) читают нарушения **только** из
`document_issue` — у любой формы, какой бы версией контракта она ни пришла. Межформенная `1370(отчётная дата) − 1370(начало
года) = 2400` — **мягкая**, считается ядром по агрегату формы по коду (§9.5 п. 2) и не блокирует подтверждение: пара —
другой документ формы того же ЮЛ с тем же годом и кварталом периода (файл не `SUPERSEDED`; подтверждённый
предпочтительнее, далее самый поздний `created_at`); считается, только если найдены пара и обе колонки. Результат в
@ -561,22 +569,26 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou
Показ пятизначных подстрок (AURORA-1527, решение владельца 16.10.2026): ядро отдаёт у каждой строки формы вычисляемое
`parent_code` (правило выше); экран показывает подстроки под родителем, пустые подстроки свёрнуты за переключателем.
**Акцепт** — по жёстким нарушениям, с комментарием (1–2000 символов), `POST /documents/{id}/statement/checks/accept`;
без нарушений — 409 `NOTHING_TO_ACCEPT`. Акцепт **сбрасывается любой правкой формы**. Аудит акцепта —
без нарушений — 409 `NOTHING_TO_ACCEPT`. Акцепт **сбрасывается любой правкой формы** (с v2.0 — правкой данных:
подтверждение ячейки или кода без изменения его не сбрасывает, §9.5 п. 9). Аудит акцепта —
`RECOGNIZED_DATA_EDIT`. Формулы одни для редакций `66n` и `fsbu4` (`ASSUMPTION(OQ-12, OQ-146)`); редакцию ядро определяет
по дате периода документа и отдаёт в ответе (`edition`: `66n` при дате ≤ 2024-12-31, `fsbu4` — позже, `null` без даты;
OQ-145); тем же правилом сервисы выбирают редакцию упорядоченного перечня кодов (§11.6).
OQ-145); тем же правилом сервисы разрешают ничью между редакциями упорядоченного перечня кодов (§11.6, §11.9).
**Полный состав формы (arm-core#80, решение владельца 09.10.2026).** Экран проверки показывает все строки формы
редакции документа (`edition`): справочник `arm-core/api/rules/statement_lines.yaml` (66н и ФСБУ 4/2023, ББ и ОФР, из
шаблонов банка `fixtures/statements-xlsx/templates/`). Строки и ячейки, которых нет в источнике, ядро достраивает в
ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку
(`line_code_original_value = NULL` — признак «не из источника») и ячейку как правку аналитика (аудит
`RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная
ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку (признак «не из
источника» — до v5.19 `line_code_original_value = NULL`, с v5.19 — `row_index = NULL`) и ячейку как правку аналитика
(аудит `RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная
правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не
достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом. **С v2.0** (change
`unified-statement-normalization`): справочник ядра — только словарь имён (код → наименование по редакциям); порядок и
состав редакций ядро берёт из копии упорядоченного перечня кодов `arm_statements` (`sync-statements --check` сверяет, что
все коды словаря имён есть в перечне); код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не
`unified-statement-normalization`): справочник ядра — только словарь имён (код → наименование по редакциям; у кода может
быть несколько имён — синонимы и законные повторы кода в бланке, Р-20: 1220 «Налог на добавленную стоимость по
приобретенным ценностям» и «НДС, начисленный по отгрузке», 1520 «Кредиторская задолженность» и «Обязательство по
аренде»; загрузчик повтор кода не запрещает, сверка наименования — с любым именем кода, шаблонная строка берёт первое);
порядок и состав редакций ядро берёт из копии упорядоченного перечня кодов `arm_statements` (данные —
`contracts/statement-code-orders.yaml`; `sync-statements --check` сверяет, что все коды словаря имён есть в перечне); код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не
достраивается; строки `unreadable` / `missing` шаблонной строкой молча не заменяются — показываются отдельно со статусом.
Наименование строки из источника хранится как распознано; экран показывает справочное имя по коду, файловое — рядом
(Р-3, AURORA-1406 / 1522).
@ -686,14 +698,19 @@ INITIATED; в Срезе 2c консолидаций ещё нет, поэтом
`DOCUMENT_NOT_STATEMENT`.
**Документ формы при приёме контракта v2.0** (change `unified-statement-normalization`, Р-19; правило выше остаётся для
результатов 1.x на окне выкладки). `RECOGNIZED` — только если одновременно: `document.status = done` из результата; в
`metrics.warnings` файла нет предупреждений уровня `error` (потерянный json или таблица ИС — решение № 28 среза 3a;
`needs_check` соседнего документа и статус конверта не учитываются); `counterparty_check ≠ MISMATCH`; определены
результатов 1.x на окне выкладки). `RECOGNIZED` — только если одновременно: `document.status = done` из результата и он согласован с
содержимым документа (нет строк `unreadable` / `missing` с суммой или текстом в значениях, нет строк, значений и полей
шапки с `needs_review`, нет `checks.relations_failed` и предупреждений документа уровня `error`; иначе ядро считает
документ `needs_check` и пишет `RESULT_STATUS_INCONSISTENT` `info` — страховка от ошибки сервиса); в `metrics.warnings`
файла нет предупреждений уровня `error` (потерянный json, таблица ИС или исключение разбора таблицы — решение № 28 среза
3a; `needs_check` соседнего документа и статус конверта не учитываются); `counterparty_check ≠ MISMATCH`; определены
`period_year` и `period_quarter` (дата — конец квартала); у каждой строки `ok` с неуверенным кодом (`line_code_confidence
< 100`) распознанное наименование совпало со справочным по коду (Р-16, правило совпадения — §11.9); в файле нет второй
формы того же типа и периода (Р-18). Иначе `NEEDS_CHECK`. Проверки §7, `needs_review` и предупреждения сервиса при
загрузке не пересчитываются. После правки или акцепта статус пересчитывается общим кодом (§11.9) с подтверждениями
аналитика и теми же условиями ядра; `RECOGNIZED` после правки — только вручную, как и прежде.
формы того же типа и периода (Р-18; «того же периода» — та же пара `period_year`, `period_quarter`, формы без квартала не
считаются). Иначе `NEEDS_CHECK`. Проверки §7, `needs_review` и предупреждения сервиса при загрузке не пересчитываются.
После правки или акцепта статус пересчитывается общим кодом (§11.9) с подтверждениями аналитика и теми же условиями ядра;
`RECOGNIZED` после правки — только вручную, как и прежде. Документ, принятый по 1.x, после правки пересчитывается так же
(общим кодом), его `LEGACY_WARNING` и `MAPPING_WARNING` — `info` и статус не держат.
### 9.4 Задание (`recognition_status`): queued → published → processing → done | needs_check | error | timeout → dlq;
replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `reply_deadline` OCR 20 мин (решение владельца 09.10.2026; note DBML «5 мин» — OQ-159), парсер 5 мин.
@ -733,15 +750,29 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `
формы из разных файлов не склеиваются (OQ-140), строки без кода не хранятся (OQ-36, OQ-141, OQ-149). Статус документа формы —
§9.3.
**Формы по контракту v2.0** (change `unified-statement-normalization`; правила выше — для результатов 1.x на окне
выкладки, у них поля v2.0 заполняются по умолчанию: `line_status = ok`, `row_index` по порядку, `text =
string_value`, `needs_review` значений — по прежнему правилу «правлено или уверенность 100 и `is_recognized`»).
Результат пишется как пришёл, без повторной дедупликации: каждая строка — `financial_statement_line` с `row_index`,
выкладки, у них поля v2.0 заполняются по умолчанию: `line_status = ok`, `row_index` по порядку строк результата с 0,
`line_code_original_value = line_code`, `source_ref = null`, `text = string_value`, `needs_review` значений — по
прежнему правилу «правлено или уверенность 100 и `is_recognized`», `form_variant = null` (§7 — полный набор),
`header_source` — из полей шапки 1.x; нарушения §7 ядро считает один раз копией `arm_statements` и пишет в
`document_issue` (`source = core`, §7 «Ядро»); предупреждения маппинга 1.x по документу (повтор кода, значение вне
колонок) — `document_issue` `MAPPING_WARNING` `info` (`source = core`; статус при приёме — по D-6, после правки они
статус не держат); строки `metrics.warnings` 1.x — в `recognition_job.metrics.warnings` структурами `{code:
"LEGACY_WARNING", severity: "info", message}` уровня файла; `statements_version` задания — `null`, и
`STATEMENTS_VERSION_MISMATCH` у такого документа не пишется).
Результат пишется как пришёл, без повторной дедупликации: каждая строка — `financial_statement_line` с `row_index`
(у строки из результата всегда не `null`: `row_index IS NULL` — признак строки, созданной правкой),
`source_ref` (`ref`, уникален в форме), `line_status`, `line_code` (`null` у `unreadable` / `missing` / `detail`),
`line_code_text`, `parent_row_index` (родитель `detail`), `needs_review`; повтор кода — законный случай, хранятся все
`line_code_original_value` (= `line_code` результата; правками не меняется), `line_code_text`, `parent_row_index`
(родитель `detail`), `needs_review`; повтор кода — законный случай, хранятся все
строки (Р-4; строки без кода хранятся — Р-8, отменяет OQ-36 / OQ-141). Значения — с `source_text` (`values[].text`) и
`needs_review`; колонки — с `period_kind` и `label_confidence`; шапка — с `form_variant` и `needs_review_fields`;
`checks` и предупреждения документа — в `document_issue` (источник `service`) вместе с проверками ядра уровня `info`
(`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`); версия общего кода —
`needs_review`; колонки — с `period_kind` и `label_confidence`; шапка — с `form_variant`, `needs_review_fields` и
`header_source` (`<поле>_text` и `<поле>_confidence` всех полей; `*_original_value` — как прежде: `inn`,
`report_date_text`, `units_text`; `inn_confidence` результата, `organization_name_original_value` =
`organization_name`); `checks`
и предупреждения документа — в `document_issue` (источник `service`; нарушение — `kind = relation`, `code =
RELATION_FAILED`, `error` или `kind = soft`, `code = RELATION_SOFT`, `info`, с `codes`, `relation`, `expected`,
`actual`, `column_id`; предупреждение о столбце вне колонок формы — с `column_index`) вместе с проверками ядра уровня
`info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`); версия общего кода —
`recognition_job.statements_version`. Каждый `BALANCE` / `INCOME` становится отдельным документом с `file_ordinal`:
вторая форма того же типа в файле **хранится** (Р-10, Р-15; ни одна из двух форм одного типа и периода не
автоподтверждается — Р-18); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл.
@ -774,9 +805,11 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `
`CHECKS_NOT_PASSED` (список нарушений — в `detail.failures`: `column_index`, `relation`, `expected`, `actual`); порядок
409: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `CHECKS_NOT_PASSED`.
**С v2.0** (Р-13, Р-18) порядок: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` →
`PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing` с непустой суммой; `detail.lines[]
{line_id, row_index, line_code_text}`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая
форма того же типа и периода; `detail.document_id`; между файлами — автозамена п. 3) → `CHECKS_NOT_PASSED`.
`PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя бы в одной ячейке
непустая сумма или нераспознанный текст — `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index,
line_code_text}`; строки без значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же
типа и периода — та же пара `period_year`, `period_quarter`; `detail.document_id`; между файлами — автозамена п. 3) →
`CHECKS_NOT_PASSED` (нарушения — записи `document_issue` вида `relation` без акцепта, у любой формы).
Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не допускает).
При подтверждении пишется `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента (`MISMATCH`) и низкая
уверенность значений подтверждение **не блокируют** (как у ОСВ): защита — статус `NEEDS_CHECK` и подсветка. Мягкие проверки
@ -809,7 +842,8 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `
Правятся **суммы** ячеек (`values[] {line_id, column_id, amount}`) и три поля шапки: `inn` (10 или 12 цифр, иначе 422
`INVALID_INN`, в том числе `null` — очистить ИНН нельзя; пересчитывается `counterparty_check`), `report_date` (пересчитываются `period_year`, `period_quarter`,
`reporting_date` по правилу периода п. 2) и `units` (непустая строка до 50 символов). Код и название строки, добавление и
удаление строк и колонок не правятся. **Только до подтверждения:** документ `CONFIRMED` — 409 `DOCUMENT_READONLY`
удаление строк и колонок не правятся (до v2.0; с v2.0 код и статус строки, дата колонки и наименование организации
правятся — см. ниже, название строки и удаление строк и колонок — по-прежнему нет). **Только до подтверждения:** документ `CONFIRMED` — 409 `DOCUMENT_READONLY`
(исправление подтверждённой формы — новым файлом через автозамену, п. 3); файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`; не
форма — 409 `DOCUMENT_NOT_STATEMENT`; ячейка не этого документа — 422 `UNKNOWN_CELL`. Исходное сохраняется: у значения при
первой правке `original_amount` = прежняя сумма, `is_modified = (amount ≠ original_amount)` (возврат к исходному снимает
@ -819,25 +853,39 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `
другого файла ЮЛ, привязывает документ к версии лишь при подтверждении. Порядок блокировок — как у подтверждения (файл →
документ).
**С v2.0** (Р-6, Р-13; change `unified-statement-normalization`) PATCH дополнительно принимает:
`values[] {line_id, column_id, confirmed: true}` — подтвердить ячейку без изменения (`is_confirmed`,
`confirmed_by/at`, `needs_review = false`, `is_modified` не ставится); `columns[] {column_id, period_date}` — дата
колонке без подписи или с неуверенной подписью (пересчитывает `is_current` и период документа); `lines[] {line_id,
line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить расшифровкой; `lines[]
{line_id, confirmed: true}` — подтвердить неуверенный код. Переходы: `unreadable` / `missing` / `detail` → `ok`
назначением кода; `unreadable` / `missing` / `ok` → `detail` отметкой; `ok` → `ok` сменой кода (прежний — в
`line_code_original_value`). Назначенный или подтверждённый код — `line_code_confidence = 100`, `line_code_modified`,
`needs_review` строки = `false`; у `detail` родитель пересчитывается как ближайшая строка `ok` выше по `row_index`. Код
— 4–5 цифр (422 `INVALID_LINE_CODE`), может совпадать с существующим (Р-4); незнакомый словарю — `info`; шаблонная
строка с тем же кодом, созданная правкой с суммами, — 409 `LINE_EXISTS`, без сумм — удаляется; `line_status = ok`
без кода — 422 `LINE_CODE_REQUIRED`; элемент без действия или с несколькими — 422 `INVALID_LINE_PATCH`; чужая строка —
422 `UNKNOWN_LINE`, чужая колонка — 422 `UNKNOWN_COLUMN`; правка суммы по `line_code` при наличии строки `ok` с этим
кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк нет (первичка не мутируется, `detail` — способ вывести
строку из сумм). Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Правка
`header.report_date` пересчитывает `period_end` колонок с `period_kind = relative:*` общим кодом (`null` — `null`).
`header.organization_name` (непустая строка до 1000 символов; `organization_name_modified = true`,
`organization_name_original_value` не меняется; пересчитывается `counterparty_check`; снимает `NO_ORGANIZATION_NO_INN`;
аудит — как у ИНН); `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты без
изменения: поле уходит из `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит (у бланка 66н только с
относительными подписями неуверенная дата иначе не подтверждается ничем, §11.9);
`values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения (`is_confirmed`,
`confirmed_by/at`, `needs_review = false`, `is_modified` не ставится) или снять подтверждение (`is_confirmed = false`,
`needs_review` пересчитывается); `columns[] {column_id, period_date}` — дата колонке без подписи или с неуверенной
подписью (обязательная дата, `null` — 422 ошибка валидации; пересчитывает `is_current` и период документа);
`lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить
расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить неуверенный код или снять подтверждение.
Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail`
отметкой; `ok` → `ok` сменой кода. Обратного перехода `detail` → `unreadable` / `missing` нет: ошибочная отметка
исправляется назначением кода, исходное состояние — перезагрузкой файла. `line_status` в PATCH принимает только
`"detail"`: `"ok"` без кода — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только `line_code`). Назначенный или
подтверждённый код — `line_code_confidence = 100`, `line_code_modified`, `needs_review` строки = `false`;
`line_code_original_value` не меняется (код из источника; у строк без кода — `null`); у `detail` родитель
пересчитывается как ближайшая строка `ok` выше по `row_index`. Код — 4–5 цифр (422 `INVALID_LINE_CODE`), может
совпадать с существующим (Р-4); незнакомый словарю — `info`. **Строка, созданная правкой** — `row_index IS NULL`
(шаблонная): если строка с назначаемым кодом — такая и у неё есть суммы, — 409 `LINE_CODE_TAKEN`; без сумм — она
удаляется (её `id` убирается из `document_issue.line_ids`), назначенная строка источника занимает её место. Строки
источника (`row_index` не `null`) с тем же кодом не удаляются и не мешают — повтор кода законен. Элемент без действия
или с несколькими — 422 `INVALID_LINE_PATCH`; чужая строка — 422 `UNKNOWN_LINE`, чужая колонка — 422 `UNKNOWN_COLUMN`;
правка суммы по `line_code` при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления
строк источника нет (первичка не мутируется, `detail` — способ вывести строку из сумм). Строки, созданные правкой:
`row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Правка
`header.report_date` пересчитывает `period_date` колонок с `period_kind = relative:*` общим кодом (`null` — `null`).
После правки ядро пересчитывает `checks`, `needs_review` и статус копией `arm_statements` (§11.9): правленое или
подтверждённое значение и строка не имеют `needs_review`, акцепт снимает жёсткие нарушения; записи `document_issue`
видов `relation` / `soft` заменяются целиком, предупреждения сервиса сохраняются, кроме снимаемых правкой (таблица
§11.9). Расхождение `statements_version` задания и копии ядра — `info` `STATEMENTS_VERSION_MISMATCH`. Один PATCH —
§11.9). **Акцепт** сбрасывается правкой данных — суммы, кода или статуса строки, даты колонки, поля шапки;
подтверждение ячейки или кода (`confirmed`) суммы не меняет и акцепт не сбрасывает. Расхождение `statements_version`
задания и копии ядра — `info` `STATEMENTS_VERSION_MISMATCH` (у заданий 1.x, где версии нет, — не пишется). Один PATCH —
один пересчёт и одна запись `RECOGNIZED_DATA_EDIT` (`before/after` по кодам, статусам, родителям, подтверждениям).
10. **Исходный файл и комментарии (Срез 3b).** `GET /files/{id}/content` (`DOCUMENT_VIEW`, policy по ЮЛ) отдаёт исходный
объект из S3 потоком через ядро (в контуре MinIO для пользователя может быть недоступен, а через ядро действует
@ -1064,8 +1112,9 @@ json>;table:<i>` (как в golden-эталонах); шапка json копир
**Ошибки набора.** Ошибочный json (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном
соседе — предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в тексте код и сообщение ИС; решение
№ 28 среза 3a: потерянный документ, например ОФР, не должен остаться незамеченным); все json ошибочны — конверт `error`
с кодом первого. Исключение при разборе таблицы — `TABLE_PARSE_ERROR` (`error`) документа, остальные таблицы
разбираются; ни одной таблицы не разобрано — `error RECOGNITION_FAILED`.
с кодом первого. Исключение при разборе таблицы — предупреждение **файла** `TABLE_PARSE_ERROR` (`error`, `locator =
file:<имя json>;table:<i>`; документа у такой таблицы нет), остальные таблицы разбираются; как и `JSON_FAILED`, оно
блокирует `RECOGNIZED` всех документов файла (§9.3); ни одной таблицы не разобрано — `error RECOGNITION_FAILED`.
**Перенос из старого ядра.** Источник очистки и разборов — модуль `DKB_ARMKA` `api/src/services/ofr_result_conversion/`
(`utils.py`, `date_utils.py`, `consts.py`; локально — `reference-old-arm/api/src/core/PDFWorker/`); решение владельца
@ -1090,14 +1139,17 @@ json>;table:<i>` (как в golden-эталонах); шапка json копир
иначе текст с признаком ОПФ строкой выше метки), ИНН 10/12 цифр у метки «ИНН» (ИНН аудитора не берётся: строки после
метки «аудитор» исключены), ОКПО, ОКОПФ, ОКФС, ОКЕИ, адрес, единицы, дата (подзаголовок формы или тройка «число | месяц
| год» у метки «Дата»; для ОФР — конец периода). Шапка другого листа не используется. Найденное поле — `Cell` с
`confidence = 100`, не найденное — `null`.
`confidence = 100`, не найденное — `null`. ОКЕИ — поле `okei` шапки контракта.
3. **Ячейки**: `text` — как показывает числовой формат ячейки, `value` — число по формату (tp#42, OQ-138 пересмотрен
15.10.2026): масштабирующие запятые (`#,##0,` → тысячи), число знаков после точки, секции и условия (`[=0]"-"`,
`[=-646532]"(647)"`; литерал условной секции разбирается как текст), округление half-up, знак — от сырого значения,
условия сравниваются с точностью 15 значащих цифр; `General` / пустой формат — значение как в ячейке; формат, который не
удалось разобрать (`%`, экспонента, дробь, даты, `@`, неизвестные знаки вне кавычек, больше четырёх секций, условие не
по шаблону, результат вне `numeric(18,2)`), — значение как в ячейке и одно `NUMBER_FORMAT_UNPARSED` (`info`) на документ
с перечнем форматов; xlsb — стили не читаются, `XLSB_NO_FORMATS` (`info`). `ref = <лист>!<A1>`, `confidence = 100`.
с перечнем форматов; xlsb — стили не читаются, `XLSB_NO_FORMATS` (`info`). `ref = <лист>!<A1>` по правилу Excel: имя
листа берётся в одинарные кавычки, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф
внутри имени удваивается (`Баланс!C27`, `'Бухгалтерский баланс. Лист 1'!C14`; решение владельца 18.10.2026, Р-22 /
S-15); `confidence = 100`.
4. **Склейка листов-продолжений — до раскладки.** Строки продолжения дописываются в конец сетки открытой формы по
маркеру «Форма … с.2» или по позиции листа; граница листа — в `segment_breaks` (всегда граница сегмента раскладки).
Тип маркера сверяется с типом формы: расхождение — отдельный кандидат и `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без
@ -1116,7 +1168,10 @@ json>;table:<i>` (как в golden-эталонах); шапка json копир
**Шапка — одно правило разбора** по `Cell.text`: ИНН — только цифры, **без замены двойников** (иначе «3|1 21 2|» из aep_3
даёт лишние единицы и `MISMATCH` с ЮЛ), длина 10 или 12, иначе `null`; дата отчёта — словарь периодов §11.3 п. 3;
единицы — словарь единиц. Сырой текст и уверенность каждого поля — в контракт (`<поле>_text`, `<поле>_confidence`).
единицы — словарь единиц пакета `statement_units.yaml` (написания «тыс. руб.», «в тыс. рублей», «руб.», «млн руб.» → `тыс.
руб.` / `руб.` / `млн руб.`); единицы не найдены по тексту, а ОКЕИ есть — по коду ОКЕИ тем же словарём (384 → `тыс. руб.`,
383 → `руб.`, 385 → `млн руб.`); ОКЕИ — только цифры. Сырой текст и уверенность каждого поля — в контракт
(`<поле>_text`, `<поле>_confidence`); ядро хранит их в `financial_statement_meta.header_source`.
### §11.3 Шаг 2 — раскладка таблицы
@ -1161,7 +1216,8 @@ json>;table:<i>` (как в golden-эталонах); шапка json копир
«2025 03 31», «30/09/25»; иначе склеенные цифры — 8 → ДДММГГГГ / ГГГГММДД / ГГГГДДММ (первый валидный), 4 → год (31.12,
1990–2100), 6 → ДДММГГ; хвост «0:00:00» отбрасывается; двузначный год → 20YY; квартал / полугодие / N месяцев → последний
день последнего месяца; не дата → `null`). Строки `test_dates.py::test_parses_real_forms` шлюза — обязательные тесты
словаря. Результат — `period_end` и `period_kind`:
словаря. Результат — `period_date` (конец периода колонки; во внутренних типах пакета может называться иначе, в
контракте и БД — `period_date`) и `period_kind`:
- абсолютные подписи → `absolute`;
- относительные подписи бланка 66н без впечатанных дат (Р-6): «На отчётную дату», «За отчётный период» →
`relative:current` (= эффективная дата); «На 31 декабря предыдущего года» → `relative:prev_year_end` (31.12 года
@ -1170,7 +1226,7 @@ json>;table:<i>` (как в golden-эталонах); шапка json копир
- подписи нет → `none`.
**Эффективная дата документа** — дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок (та же формула,
что период формы в ядре, §9.5 п. 2); нет ни той, ни другой → у относительных колонок `period_end = null`, вид
что период формы в ядре, §9.5 п. 2); нет ни той, ни другой → у относительных колонок `period_date = null`, вид
остаётся относительным, ядро досчитает после правки даты отчёта. Подпись OCR с уверенностью ниже 100, разобранная в
дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два года, 31 декабря
предыдущего и предшествующего ему года; иначе `COLUMN_DATE_UNCERTAIN` (`error`, колонка).
@ -1179,26 +1235,40 @@ json>;table:<i>` (как в golden-эталонах); шапка json копир
заголовок («На» + «20__ г.», перенос внутри ячейки) склеивается. Роль `title` — только строки выше найденной строки
заголовка (титульный блок «Дата 30 | 9 | 2025»); в сегменте без заголовка роли `title` нет, строки выше первого кода с
содержимым идут в данные. Заголовка нет ни у одного сегмента → колонки значений первого сегмента — столбцы правее кода,
где в строках с кодами числа, `label` пустая, `period_end = null`, `period_kind = none`, одно `HEADER_ROW_NOT_FOUND`
где в строках с кодами числа, `label` пустая, `period_date = null`, `period_kind = none`, одно `HEADER_ROW_NOT_FOUND`
(`error`; прежний синтез шапки шлюза и «рулинг 06.10.2026» заменены этим правилом). Заголовок есть хотя бы у одного
сегмента — остальные берут его колонки без предупреждения (листы 2–5 ББ НПЗ без шапки остаются `done`).
5. **Строка нумерации** «1 2 3 4 5» — строка, где не меньше трёх непустых ячеек, каждая — целое число, и числа идут
подряд с 1 слева направо — роль `numbering`, в данные не попадает (AURORA-1517).
5. **Строка нумерации** «1 2 3 4 5» — роль `numbering`, в данные не попадает (AURORA-1517). Строка — нумерация, если в
ней не меньше трёх непустых ячеек, каждая после замены букв-двойников (§11.3, «1 2 З 4» → 1 2 3 4) — целое число,
числа слева направо возрастают на 1, начинаются с 1 или 2 и допускают **один пропуск** («1 2 4 5», «2 3 4 5» без
первой ячейки; в Excel Межениновской в столбце кодов строк нумерации стоит «2»). Решение владельца 18.10.2026 (Р-22,
S-13): иначе сбой OCR в нумерации превращает её в строку `unreadable` с «суммами» и блокирует подтверждение.
Запись нумерации в столбце кодов («2», «3») кодом не считается — вне кодовых диапазонов.
6. **Подпись без периода** (Р-6): период по словарю (абсолютный или относительный) → колонка с датой; подпись из
словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск, `COLUMN_SKIPPED` (`info`);
иначе, если в столбце есть числа в строках с кодами (OCR-мусор «| | г» с уверенностью 17, обрезанная или самодельная
подпись), — колонка остаётся с подписью как есть, `period_end = null`, `period_kind = none`, `COLUMN_WITHOUT_DATE`
подпись), — колонка остаётся с подписью как есть, `period_date = null`, `period_kind = none`, `COLUMN_WITHOUT_DATE`
(`error`, колонка); аналитик ставит дату через PATCH (§9.5 п. 9); иначе пропуск. Колонка без даты не мешает периоду
формы (эффективная дата), но межформенная проверка и гудвилл «на отчётную дату» по ней не считаются, пока дата не
проставлена. Уверенность подписи — `label_confidence` (Excel — 100, подписи нет — `null`).
7. **Столбец наименования** — подпись, начинающаяся с «наименование», в строке заголовка; иначе столбец левее кода с
наибольшим числом текстов, кроме «Пояснения» (пустые или ≤ 3 символов); наименование строки — ячейка этого столбца,
пуста — ближайшая текстовая ячейка левее кода, кроме «Пояснения».
8. **Номер колонки** (`columns[].column_index`, `values[].column_index`, `column_index` предупреждений) — номер столбца
в сетке кандидата шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд: 2, 4, 5), у Excel — номер
столбца листа с 0 (столбец D — 3). Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками
первого сегмента по порядку и несут их номера. Предупреждение о столбце, которого нет среди `columns[]`
(`COLUMN_SKIPPED`), несёт номер этого столбца в сетке; ядро хранит его в `document_issue.column_index` (колонки формы
нет — `column_id = null`).
### §11.4 Шаг 3 — отбор строк данных и статусы
Строка данных — строка сетки с ролью `data`, у которой есть содержимое: код, число или непустой текст в колонке значений
с уверенностью ниже 100 (признак потерянной суммы: «[190 :», «" :» в aep_3). У строки: `row_index` (номер в документе с
Строка данных — строка сетки с ролью `data`, у которой есть **содержимое**: в ячейке кода есть хотя бы одна цифра
(разобранный код или мусор вроде «44 30\nI |» aep_6), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`),
или непуста с уверенностью ниже 100 (признак потерянной суммы: «[190 :», «" :» в aep_3). Строка только с текстом
наименования — не строка данных (заголовок раздела). Строка `unreadable` без значений («44 30\nI |» при пустых суммах)
— строка данных: документ `needs_check` (не все строки `ok` / `detail`), но подтверждение она не блокирует
(`LINES_UNRESOLVED` — только строки со значениями, §9.5 п. 4). У строки: `row_index` (номер в документе с
0, по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:<row_index ИС>`, разлепленные
`row:<i>#<k>` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!<A1>`, без столбца кодов — первой непустой
ячейки), статус, ячейки кода и наименования, ячейки значений по колонкам сегмента, `parent_ref`. **Статусы:**
@ -1280,19 +1350,24 @@ parent_ref, needs_review, values}`. `line_code` — разобранный ко
**без срезания до 99 по справочнику**; `line_name` — распознанный текст наименования как есть, с уверенностью источника
(Р-3: имя из справочника шлюза `api/rules/statement_lines.yaml` отменено, файл удаляется; соответствие «код →
наименование» — только в ядре, §7 «Полный состав формы»). **Редакция** (`66n` / `fsbu4`) в сервисах нужна только для
упорядоченного перечня кодов (§11.9) и выбирается по эффективной дате (`fsbu4` — после 2024-12-31, как `edition` ядра,
OQ-145); без даты проверяются обе редакции. Прежний выбор редакции шлюза для имён (по дате, по кодам, «редакция не
упорядоченного перечня кодов (§11.9): проверяются **обе** редакции, берётся та, где подпоследовательность длиннее, а при
равенстве — по эффективной дате (`fsbu4` — после 2024-12-31, как `edition` ядра, OQ-145): в 2025 году формы сдают и на
бланке 66н (Межениновская, 3 кв. 2025), и выбор только по дате не подтвердил бы её код 1120 (в перечне `fsbu4` его нет). Прежний выбор редакции шлюза для имён (по дате, по кодам, «редакция не
определена» / «не заполнена» → `needs_check`) отменён.
### §11.7 Шаг 6 — тип формы, вариант, документ
1. **Тип** — большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений →
`DISCLOSURE`; совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и `TYPE_CONFLICT`
(`error`); кодов нет — `type_hint`. Без столбца кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`,
иначе `needs_check` с `CODE_COLUMN_NOT_FOUND` `error`); `type_hint` нет → у OCR таблица ИС с содержимым —
предупреждение файла `TABLE_LOST` (`error`), у Excel — лист «прочее», `SHEET_OTHER` (`info`). `DISCLOSURE` по `type_hint`
— шаги 2–7 не выполняются; `DISCLOSURE` по кодам — строки и колонки отбрасываются (ядро строки пояснений не хранит),
проверки не считаются, документ `done`.
(`error`); кодов нет — `type_hint`. Поиск столбца кодов (шаг 2 п. 1) выполняется **всегда**, в том числе при
`type_hint = DISCLOSURE`: подпись «Пояснения» или имя листа «Расшифровка» над таблицей с кодами 1110–1700 не должны
превратить баланс в пустое пояснение — большинство кодов в отрезке ББ или ОФР даёт тип по кодам и `TYPE_CONFLICT`
(`error`). Без столбца кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с
`CODE_COLUMN_NOT_FOUND` `error`); `type_hint` нет → у OCR таблица ИС с содержимым — предупреждение **файла**
`TABLE_LOST` (`error`, `locator = file:<имя json>;table:<i>`), у Excel — лист «прочее», предупреждение **файла**
`SHEET_OTHER` (`info`, `locator = sheet:<имя>`); документа в обоих случаях нет. `DISCLOSURE` (по `type_hint` без
кодов форм или по кодам прочих форм) — шаги 3–7 не выполняются, `columns` и `lines` — пустые массивы (ядро строки
пояснений не хранит), `checks` нет, документ `done`.
2. **Вариант формы** `form_variant` — только по структуре кодов (ОКУД 0710096 — код КНД комплекта, на бланках
0710001 / 0710002, признаком не служит): ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`; ОФР — есть 2400 или
2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` / `OTHER` — `null`. Шаг 7 и перечень
@ -1310,58 +1385,84 @@ OQ-145); без даты проверяются обе редакции. Пре
### §11.9 Шаг 8 — `needs_review`, статус, предупреждения
**Подтверждение проверкой** (Р-5, Р-7, Р-11, Р-17). Проверка §7 — арбитр, уверенность — подсказка там, где проверки нет.
**Подтверждение проверкой** (Р-5, Р-7, Р-11, Р-17, Р-21). Проверка §7 — арбитр, уверенность — подсказка там, где
проверки нет.
- Значение **нуждается в покрытии**, если `confidence < 100` (Р-5: у ИС уверенность дискретная — 100, ~80, 71, 67, 60,
50, 25, 0; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80) или `is_recognized = false`
(грязный текст, замена двойников, разлепленная строка). Чистые значения в покрытии не нуждаются.
- Значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось (в
пределах допуска или округления), и `amount` значения не `null`.
- Значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое
посчитано и сошлось: **жёсткое** — в пределах допуска ±1 или округления 0,001 %; **мягкое** — только точно, в пределах
допуска ±1, без «округления» (Р-21, решение владельца 18.10.2026). Мягким покрываются строки, у которых жёстких
соотношений нет: 2400, 2410, 2411, 2412, 2460 (ОФР полного набора; `2400` упрощённого — тоже мягкое, S-5), 2510, 2520,
2530, 2500 (`2500 = 2400 + 2510 + 2520 + 2530`), пятизначные подстроки («сумма подстрок = родитель»).
- **Знак** члена `abs_terms`, нуждающегося в покрытии: подтверждён, если форма записи знака (скобки, минус, без знака —
по `text`) совпадает с формой у большинства членов `abs_terms` той же колонки с непустым `amount`, `is_recognized =
true` и без правки аналитика; не совпадает или сравнивать не с чем — не подтверждён. «(7744536}» подтверждается, если
2210 и 2220 той же колонки в скобках; «{38378)» при остальных в скобках — подсвечивается.
true` и без правки аналитика; не совпадает или сравнивать не с чем — не подтверждён (то же правило — при покрытии
мягким соотношением, например 2410 в `2400 = 2300 − 2410 + 2420`). «(7744536}» подтверждается, если 2210 и 2220 той же
колонки в скобках; «{38378)» при остальных в скобках — подсвечивается.
- `needs_review = true`, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех
ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности. Иначе `false`: сырой
текст и уверенность остаются в подсказке ячейки.
текст и уверенность остаются в подсказке ячейки. Мягкое соотношение, не сошедшееся точно, подсветки не даёт.
Не покрыто проверками и остаётся на уверенности: справочные строки ОФР 25xx / 29xx, детализация без проверки родителя,
`DISCLOSURE`, колонки только с мягкими соотношениями. Риск двух взаимно компенсирующих ошибок в одном соотношении
принимается. Закрывает AURORA-1524 без ущерба для 1519.
Не покрыто проверками и остаётся на уверенности: строки 29xx (прибыль на акцию), 2420 и 2530 вне сошедшихся мягких
соотношений, детализация без строки-родителя, `DISCLOSURE`, колонки, где код не участвует ни в одном посчитанном
соотношении. Риск двух взаимно компенсирующих ошибок в одном соотношении принимается. Закрывает AURORA-1524 без ущерба
для 1519.
**Код строки** (Р-5, Р-12, Р-16). Код неуверенный — `line_code_confidence < 100` или заменён двойник; структурная
**Код строки** (Р-5, Р-12, Р-16, Р-20). Код неуверенный — `line_code_confidence < 100` или заменён двойник; структурная
проверка — **только для неуверенных кодов** (у Excel и чистых OCR-кодов расхождение с перечнем — `info` ядра
`CODE_NOT_IN_EDITION`, не `needs_check`: пятизначные подстроки 1С и нестандартные коды клиента не блокируют
автоподтверждение). В пакете — **упорядоченный перечень кодов** каждой формы по редакциям (66н, ФСБУ 4) и вариантам, без
наименований, в порядке бланка (итоги после слагаемых: 1110 … 1190, 1100; 2110, 2120, 2100); источник — объединение кодов
шаблонов банка (словарь ядра) и кодов печатных форм 1С по редакции (1105, 1530, 2420, 2460, 2930 и др.). Алгоритм:
четырёхзначные коды строк `ok` документа — последовательность; для каждой проверяемой редакции — наибольшая
подпоследовательность, согласованная с порядком бланка; редакция — с более длинной (при равенстве — по эффективной дате);
уверенные коды — подтверждённые якоря; при нескольких наибольших подпоследовательностях подтверждены только общие коды.
Пятизначный код подтверждён, если подтверждён его родитель (§7) и подстрока стоит после него. Повтор кода, у которого
хотя бы одна копия неуверенна, — `needs_review` у неуверенной копии. Неподтверждённый неуверенный код — `needs_review`
строки, документ `needs_check`. Подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120)
структура не ловит — это закрывает ядро: для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение
распознанного наименования со справочным (§9.3). **Совпадение наименований:** обе строки — нижний регистр, `ё` → `е`,
всё кроме букв и цифр → пробел, пробелы схлопываются; совпало, если строки равны, или более короткая (≥ 5 символов)
входит в более длинную, или `difflib.SequenceMatcher` ≥ 0,8 — с любым справочным именем кода в любой редакции словаря
ядра; кода нет в словаре или наименование пусто — не совпало; несовпадение — `NAME_MISMATCH` (`info`) у строки. По
наименований, в порядке бланка (итоги после слагаемых: 1110 … 1190, 1100; 2110, 2120, 2100). **Данные перечня —
`contracts/statement-code-orders.yaml`** (источник; в пакете — его байтовая копия, сверяет `sync-statements --check`):
объединение кодов шаблонов банка (словарь ядра) и кодов печатных форм 1С и сканов из `fixtures/` (1105, 1130, 1140, 1215,
1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; код без образца в перечень не входит (кандидаты — OQ-168),
упрощённые варианты — по бланку (OQ-166). **Законные повторы кода** (Р-20, решение владельца 18.10.2026) — часть перечня
(`repeats`): у `fsbu4` — 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощённой
системе налогообложения» (печатная форма 1С, AURORA-1431), у `66n` — раздел III некоммерческой организации после раздела
III (aep_3). Алгоритм: четырёхзначные коды строк `ok` документа — последовательность; для **каждой** редакции перечня
(66н и ФСБУ 4 — всегда обе: Межениновская за 3 кв. 2025 сдана на бланке 66н) — наибольшая подпоследовательность,
согласованная с порядком перечня варианта формы документа (повтор допустим столько раз, сколько код стоит в перечне);
редакция — с более длинной (при равенстве — по эффективной дате: `fsbu4` после 2024-12-31, OQ-145); уверенные коды —
подтверждённые якоря; при нескольких наибольших подпоследовательностях подтверждены только общие коды. Пятизначный код
подтверждён, если подтверждён его родитель (§7) и подстрока стоит после него. Повтор кода, которого **нет в перечне**
(или копий больше, чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review` у неуверенной копии (Р-16);
повтор из перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `needs_review` строки,
документ `needs_check`. Подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120) структура не
ловит — это закрывает ядро: для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение распознанного
наименования со справочным (§9.3).
**Совпадение наименований** (ядро; S-4 с оговорками ревью, решение владельца 18.10.2026): обе строки — нижний регистр,
`ё` → `е`, всё кроме букв и цифр → пробел, пробелы схлопываются; совпало, если строки равны, или более короткая (≥ 5
символов) входит в более длинную, или `difflib.SequenceMatcher` ≥ 0,8 — с **любым** справочным именем кода в любой
редакции словаря ядра (у кода может быть несколько имён: синонимы и законные повторы — Р-20). Исключение: если хотя бы
одно из сравниваемых имён начинается с «итого по разделу», совпадение — **только равенство** («итого по разделу i»
входит в «итого по разделу ii» и похоже на него — без исключения подмена 1100 ↔ 1200 ↔ 1300 не ловилась бы именем).
Кода нет в словаре или наименование пусто — не совпало; несовпадение — `NAME_MISMATCH` (`info`) у строки. По
`fixtures/ocr` коды часто приходят с уверенностью 50 и 25 — требовать 100 нельзя.
**Поля шапки.** Дата отчёта с уверенностью ниже 100 подтверждена, если совпала с самой поздней абсолютной датой колонок;
иначе `report_date` попадает в `needs_review_fields` и `REPORT_DATE_UNCERTAIN` (`error`) — неверно прочитанный год давал
бы неверный период и автоподтверждение. ИНН и организация с уверенностью ниже 100 — `info` (их сверяет ядро: ИНН —
`MISMATCH` с ЮЛ, организация — по алиасам), в `needs_review_fields` не попадают; единицы и прочие поля на статус не
влияют.
бы неверный период и автоподтверждение. **У бланка 66н только с относительными подписями** («На отчётную дату», …)
абсолютных дат колонок нет, и неуверенная дата отчёта структурой не подтверждается никогда — такой документ всегда
`needs_check` с `REPORT_DATE_UNCERTAIN`; аналитик сверяет дату с бланком и подтверждает её (PATCH `header.report_date`
тем же значением или исправленным, §9.5 п. 9). ИНН и организация с уверенностью ниже 100 — `info` (их сверяет ядро: ИНН
— `MISMATCH` с ЮЛ, организация — по алиасам), в `needs_review_fields` не попадают (`needs_review_fields` — поля, которые
держат документ в `needs_check`; подсветка ИНН и организации на экране — по предупреждениям с `field`; S-10, решение
владельца 18.10.2026); единицы и прочие поля на статус не влияют.
**Предупреждение** — структура `{code, severity, message, line_refs?, column_index?, field?}`; уровень файла
(`metrics.warnings`) — ещё `locator?`. Место — `locator` документа, не текст. Принцип: `error` — только то, что меняет
данные или их полноту; `error` → `needs_check`, `info` на статус не влияет.
(`metrics.warnings`) — ещё `locator?`. Место — `locator` документа (у предупреждения файла — его `locator`), не текст.
`line_refs` — только `ref` строк из `lines[]` (о строках ролей `title` / `header` / `numbering` — в `message`);
`column_index` — номер столбца сетки (§11.3 п. 8). Принцип: `error` — только то, что меняет данные или их полноту;
`error` → `needs_check`, `info` на статус не влияет. Предупреждение уровня файла с `error` блокирует `RECOGNIZED` всех
документов файла (§9.3).
| Код | Уровень | Когда | Чем снимается в ядре |
|---|---|---|---|
| `JSON_FAILED` | error, файл | json набора ИС ошибочен при успешном соседе | перезагрузка |
| `TABLE_PARSE_ERROR` | error | исключение при разборе таблицы ИС | перезагрузка |
| `TABLE_PARSE_ERROR` | error, файл | исключение при разборе таблицы ИС (`locator` таблицы; документа нет) | перезагрузка |
| `TABLE_LOST` | error, файл | таблица ИС с содержимым без столбца кодов и без типа | перезагрузка |
| `CODE_COLUMN_NOT_FOUND` | error | форма `BALANCE` / `INCOME` без столбца кодов | перезагрузка |
| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в т. ч. лист-продолжение) | перезагрузка |
@ -1374,7 +1475,7 @@ OQ-145); без даты проверяются обе редакции. Пре
| `TYPE_CONFLICT` | error | тип по кодам противоречит `type_hint` | перезагрузка |
| `NO_ORGANIZATION_NO_INN` | error | нет ни организации, ни ИНН | PATCH `header.inn` / `organization_name` |
| `PERIOD_UNKNOWN` | error | нет ни даты отчёта, ни абсолютных дат колонок | PATCH `header.report_date` |
| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок | PATCH `header.report_date` |
| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок (у бланка только с относительными подписями — всегда) | PATCH `header.report_date` (в том числе тем же значением — подтверждение) |
| `XLSB_SCALE_SUSPECT` | error | xlsb без форматов, «тыс. руб.», ≥ 10 % дробных | перезагрузка |
| `INN_NOT_FOUND`, `INN_UNCERTAIN` | info | ИНН не найден / неуверен при найденной организации | — |
| `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN` | info | организация не найдена / неуверенна при найденном ИНН | — |
@ -1385,10 +1486,14 @@ OQ-145); без даты проверяются обе редакции. Пре
| `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) | — |
| `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны | — |
| `VALUE_OUT_OF_RANGE` | info | число Excel длиннее 16 целых цифр | — |
| `SHEET_OTHER` | info | лист Excel «прочее» без типа и кодов | — |
| `SHEET_OTHER` | info, файл | лист Excel «прочее» без типа и кодов (`locator = sheet:<имя>`; документа нет) | — |
| `ROW_SPLIT` | info | разлеплена склеенная строка | — |
| `OSV_*` | info | предупреждения листа ОСВ (§1) | — |
| `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH` | info, ядро | проверки ядра (§9.3, §9.5) | пересчёт |
| `RESULT_STATUS_INCONSISTENT` | info, ядро | `document.status = done` из результата противоречит строкам, `needs_review`, нарушениям или предупреждениям документа — ядро считает его `needs_check` (§9.3) | пересчёт после правки |
| `MAPPING_WARNING` | info, ядро | предупреждение маппера 1.x по документу (повтор кода, значение вне колонок) | — |
| `LEGACY_WARNING` | info, файл | строка `metrics.warnings` результата 1.x (`recognition_job.metrics`) | — |
| `RELATION_FAILED` / `RELATION_SOFT` | error / info | код записи `document_issue` вида `relation` / `soft` — нарушение §7 из `checks` (или пересчёта ядра) с `codes`, `relation`, `expected`, `actual` | пересчёт после правки; `relation` — ещё акцепт |
Назначение кода и отметка `detail` снимают нарушения, пересчитываемые §7. Новый код предупреждения вводится словарём
пакета и строкой этой таблицы; в контракте `code` — строка, не перечисление.
@ -1396,10 +1501,27 @@ OQ-145); без даты проверяются обе редакции. Пре
**Статус документа** — одна функция общего кода: `done` ⇔ раскладка найдена ∧ (организация или ИНН) ∧ период определён
(дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей
шапки с `needs_review` ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет предупреждений документа уровня
`error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и проверках. Ядро вызывает ту же
функцию после правок, с подтверждениями (`is_modified` / `is_confirmed` ⇒ `needs_review = false`) и акцептом (жёсткие
нарушения не учитываются). Квартал периода, сверка ИНН и наименований — знание ядра (§9.3), в `done` не входят. У ОСВ
`document.status = done`, когда ведомость разобрана (§1.6), её предупреждения — `info`.
`error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и проверках. Квартал периода,
сверка ИНН и наименований — знание ядра (§9.3), в `done` не входят. У ОСВ `document.status = done`, когда контрольные
суммы §1.4 листа сошлись (§1.6, S-22), её предупреждения — `info`.
**Интерфейс функции статуса** (K-14) — один для сервиса и ядра, на входе только данные, без типов источника:
`document_type`; `layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть хотя бы
одна строка из источника, `row_index` не `null`); `has_organization_or_inn`; `period_known` (дата отчёта или абсолютная
дата хотя бы одной колонки — у ядра `document.reporting_date` не `null`); строки `{line_status, needs_review}`; значения
`{needs_review}`; число жёстких нарушений вне допуска и округления; `accepted` (акцепт — жёсткие нарушения не
учитываются); уровни предупреждений документа (у ядра — записи `document_issue` вида `warning` с `severity = error`).
`needs_review` строк и значений на входе уже учитывает подтверждения: у ядра правленое (`is_modified`,
`line_code_modified`) или подтверждённое (`is_confirmed`) — `false`. Сервис собирает вход из `FormCandidate` и
результата шагов 2–7, ядро — из строк БД; так один код даёт один статус.
**Ожидание на реальных сканах** (`fixtures/ocr`, оценка ревью спеки 18.10.2026, K-4): после Р-20 (повторы 1220 / 1520 /
2410 в перечне `fsbu4` и синонимы в словаре имён) и Р-21 (мягкие соотношения ОФР) законные повторы и строки 24xx / 25xx
перестают держать документ. `needs_check` остаётся там, где есть что смотреть: строки `unreadable` / `missing` с суммами
(aep_3), значения с уверенностью ниже 100 вне соотношений и в несошедшихся соотношениях, разлепленные строки без
покрытия, неуверенная дата отчёта. Доля `done` на текущих шести документах ожидается небольшой; golden-эталоны
`fixtures/ocr/expected_normalize/` пересчитываются в PR scan-gateway (шаг 5 выкладки), фактическая доля — в его
описании.
**Статус конверта:** `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`;
документов нет — `error` (`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser — коды описывают
@ -1419,7 +1541,7 @@ OQ-145); без даты проверяются обе редакции. Пре
| Без шапки — синтез шапки и `needs_check` (рулинг 06.10.2026) | правило строки заголовка §11.3 п. 4 |
| Подпись без периода не читается (§12 п. 4) | колонка без даты и `error` (Р-6) |
| Закрывающая `}` / `]` не даёт минус (решение № 12, OQ-147) | `}` / `]` — закрывающая скобка, `is_recognized = false` |
| Длинное тире → `is_recognized = false` (решение № 20, OQ-148) | прочерк любого вида — `null`, `is_recognized = true` |
| Длинное тире → `is_recognized = false` (решение № 20, OQ-148) | прочерк любого вида — `null`, `is_recognized = true` (OQ-148 закрыт решением владельца 18.10.2026, Р-22 / S-7) |
| `is_recognized` OCR = уверенность 100 ∧ ничего не выброшено; любое предупреждение → `needs_check` | `is_recognized` — только разбор; статус — §11.9 |
| `checks` ядро игнорирует и пересчитывает §7 при загрузке | хранит как пришли, пересчитывает только после правок |
@ -1427,7 +1549,8 @@ OQ-145); без даты проверяются обе редакции. Пре
Восстановление строк по наименованию (OQ-149); сопоставление формы без столбца кодов по именам строк (OQ-139); склейка
страниц из разных файлов (OQ-140) и половин формы из одного файла (OQ-165, Р-15); приведение единиц сверх числового
формата (OQ-138); формулы §7 по редакциям и знаки 2410/2420 (OQ-12, OQ-146); образец упрощённой формы (OQ-166);
формата (OQ-138); формулы §7 по редакциям и знаки 2410/2420 (OQ-12, OQ-146); образец упрощённой формы (OQ-166); коды
перечня без образца в fixtures (OQ-168);
несколько периодов в одном файле ИС (OQ-167, допущение владельца); переключатель «как в файле» на экране (Р-14).
Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_* + document_issue` для PDF и Excel;

View File

@ -153,7 +153,7 @@
| OQ-145 | Редакция формы (`66n` / `fsbu4`) в контракте: нужна ли ядру редакция документа в конверте (необязательное поле `statementDocument`, кандидат MINOR 1.3), или ядро выбирает редакцию по `report_date` само, по тому же правилу §11.1 п. 10. Сейчас scan-gateway выбирает редакцию внутри и наружу её не отдаёт | change `slice-3a-ocr-normalizer`, открытый вопрос 2 | контракт `docs/contracts/` (v1.3), Срез 3b (маппинг форм, справочник кодов в ядре) | **закрыт решением владельца 07.10.2026:** редакция формы в контракт не добавляется (контракт v1.2); ядро определяет её по дате периода документа (`66n` при дате ≤ 2024-12-31, иначе `fsbu4`) и отдаёт в `GET /documents/{id}/statement` (`edition`), как реализовано в Срезе 3b. Досрочное применение ФСБУ 4/2023 до 2025 года — новый OQ при первом реальном случае. **18.10.2026 (change `unified-statement-normalization`):** решение сохраняется — редакция в контракт v2.0 не добавлена; сервисы выбирают редакцию тем же правилом только для упорядоченного перечня кодов (структурная проверка неуверенных кодов, `05-domain-rules.md` §11.6, §11.9) |
| OQ-146 | Словари §7 (проверки форм) и §12 (справочник кодов `statement_lines.yaml`, формулы ОФР) **по редакциям** 66н / ФСБУ 4/2023 для tabular-parser (2b) и ядра (3b): один набор правил на обе редакции или раздельные; как выбирать редакцию в ядре для Excel-пути | change `slice-3a-ocr-normalizer` (справочник с редакциями только в scan-gateway), OQ-12, OQ-139 | tabular-parser `rules/*.yaml`, ядро (Срез 3b), §7, §12 п. 2 | открыт, **решение владельца 07.10.2026: решается вместе с OQ-150** (состав редакции `fsbu4`); до тех пор допущение прежнее — scan-gateway ведёт свой `api/rules/statement_lines.yaml`, tabular-parser и ядро словарь по редакциям не используют, формулы §7 одни на обе редакции; расширяет OQ-12 и OQ-139. **18.10.2026:** словарь §7 — единственный, в общем коде `arm_statements` (копия ядра удаляется), полный и упрощённый наборы по `form_variant`; раздельные формулы по редакциям по-прежнему не вводятся |
| OQ-147 | Закрывающая скобка, распознанная OCR как `}` или `]` (`(7744536}` в `aep_3.json`): дословный перенос `parse_amount` минус не ставит — сумма положительная, `is_recognized = false`. Расширить ли множество закрывающих скобок в §11.1 п. 7 после проверки на стенде | change `slice-3a-ocr-normalizer`, решение №12; владелец 06.10.2026: «пока устроит как есть» | scan-gateway `normalizer/ocr/amounts.py`, §11.1 п. 7 | открыт: допущение — перенос дословный; знак теряется, строка подсвечена `is_recognized = false`, исходный текст — в `raw/`. **Закрыт дизайном, согласованным владельцем 17.10.2026 (change `unified-statement-normalization`, шаг 4, Р-7):** `}` и `]` принимаются как закрывающая скобка — «(7744536}» → −7 744 536, `is_recognized = false` (исправление); знак члена `abs_terms` подтверждается конвенцией колонки. `05-domain-rules.md` §11.5 п. 2. Действует с переходом scan-gateway на v2.0 |
| OQ-148 | Длинное/среднее тире «—»/«–» как прочерк: дословный `parse_amount` вырезает тире как «лишний» символ → сумма `null`, но `is_recognized = false` даже при `confidence = 100` (дефис «-» даёт `true`). Лишний `needs_check` на формах с длинным тире. Считать ли прочерк любого вида распознанным | change `slice-3a-ocr-normalizer`, решение №20 | scan-gateway `amounts.py`, §11.1 п. 7, доля `done` на реальных сканах | открыт: допущение — перенос дословный; правило уточняется после прогона на стенде. **Допущение спеки `unified-statement-normalization` (18.10.2026, решение S-7, ждёт подтверждения владельца):** прочерк любого вида (`-`, `–`, `—`, `−` без цифр) — пустое значение, `amount = null`, `is_recognized = true` (как пустая ячейка, Р-2); `05-domain-rules.md` §11.5 п. 2 |
| OQ-148 | Длинное/среднее тире «—»/«–» как прочерк: дословный `parse_amount` вырезает тире как «лишний» символ → сумма `null`, но `is_recognized = false` даже при `confidence = 100` (дефис «-» даёт `true`). Лишний `needs_check` на формах с длинным тире. Считать ли прочерк любого вида распознанным | change `slice-3a-ocr-normalizer`, решение №20 | scan-gateway `amounts.py`, §11.1 п. 7, доля `done` на реальных сканах | открыт: допущение — перенос дословный; правило уточняется после прогона на стенде. **Закрыт решением владельца 18.10.2026 (Р-22, утверждено решение спеки S-7, change `unified-statement-normalization`):** прочерк любого вида (`-`, `–`, `—`, `−` без цифр) — пустое значение, `amount = null`, `is_recognized = true` (как пустая ячейка, Р-2); тире на месте потерянной цифры ловит §7 (пустой член посчитанного соотношения); `05-domain-rules.md` §11.5 п. 2. Действует с переходом scan-gateway на контракт 2.0 |
| OQ-149 | Строки таблицы OCR, отброшенные классификацией (поведение источника, §11.1 п. 4), не восстанавливаются: с непустым нечисловым кодом (`1150 :`) и с пустым кодом, но с суммами (в `aep_3.json` — «БАЛАНС» и «Выручка»: код пуст, мусор `211(1 \|` стоит в колонке значения). Нормализатор выдаёт предупреждения `строк с нечисловым кодом пропущено: N` и `строк без кода с суммами пропущено: N` (как OQ-141 для Excel), документ — `needs_check`, содержимое строк остаётся в `raw/`. Восстанавливать ли такие строки по наименованию (словарь редакции) или по структуре формы | `fixtures/ocr/aep_3.json`, `aep_6.json`, change `slice-3a-ocr-normalizer`, решение №29, §11.1 п. 4 | scan-gateway `rows.py`/`document.py`, полнота форм, OQ-36, OQ-139, OQ-141 | открыт: допущение — строки пропускаются с предупреждением; аналитик видит расхождение итогов в проверках 3b. **18.10.2026:** остаётся открытым (Р-8); с v2.0 такие строки не пропадают — приходят со статусами `unreadable` / `missing` / `detail` и сырым текстом, аналитик назначает код или отмечает расшифровкой (`PATCH …/statement lines`); восстановление по наименованию не делается |
| OQ-150 | Состав редакции `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года) в `api/rules/statement_lines.yaml` scan-gateway: коды и наименования строк ББ и ОФР; источник — Приложения 3–4 к ФСБУ 4/2023 (черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`, OQ-142). Пока `lines: {}` | change `slice-3a-ocr-normalizer`, D-7 | scan-gateway `api/rules/`, эталоны `fixtures/ocr/expected_normalize/` для документов 2025 года, статус `done` | открыт: **данные даёт владелец**, код нормализатора не меняется; до заполнения документы 2025 года идут с именами из OCR, предупреждением «редакция fsbu4 не заполнена» и `needs_check`. Порядок заполнения — `scan-gateway/README.md` **09.10.2026, решение владельца:** состав `fsbu4` (и `66n`) для ядра принят из шаблонов банка — `arm-core/api/rules/statement_lines.yaml` (arm-core#80); словарь scan-gateway — follow-up, копии расходятся (66н scan-gateway — словарь старого ядра). **18.10.2026:** словарь имён шлюза `api/rules/statement_lines.yaml` удаляется (Р-3: наименования — только из источника, справочное имя — в ядре); для сервисов нужен состав кодов `fsbu4` в упорядоченном перечне кодов `arm_statements` — источник тот же (шаблоны банка + печатные формы 1С) |
| OQ-151 | Эмулятор ИС Сканирование: файл, совпавший по sha256 с PDF из `SCAN_FIXTURES_DIR`, у которого нет JSON-набора (`<имя>.json`), отвечает «нет пары» (`INVALID_DOCUMENT`) и **не** уходит на `SCAN_EMULATOR_DEFAULT`. Пробел спеки эмулятора: считать ли такой файл промахом и отдавать набор по умолчанию | change `slice-3a-ocr-normalizer`, спека `scan-emulator` | scan-gateway `scan_emulator`, dev-стенд (`dev/qa-guide.md` §6) | открыт: допущение — поведение как есть; PDF без JSON-пары в `fixtures/ocr` не класть |
@ -171,8 +171,6 @@
| OQ-163 | ОСВ, где ИНН контрагента выведен **отдельной строкой** рядом с наименованием, в любой раскладке: над / под × в дереве / не в дереве × с суммами / без (требование владельца 13.10.2026). До решения парсер склеивал только «над, не в дереве, без сумм» (OQ-130) и «под, в дереве» при подсказке «Контрагенты.ИНН» (OQ-162); «под, не в дереве, без сумм» отдавал ИНН следующему контрагенту | владелец, 13.10.2026; RSHB/arm-tabular-parser#35, #34 | tabular-parser §1.1, §1.3 | **решено владельцем 13.10.2026:** без подсказки в шапке строка ИНН — только с верной контрольной суммой ИНН (во всех раскладках, включая OQ-130); с подсказкой — без проверки, порядок элементов задаёт направление; «над, в дереве» — только единственный ребёнок; соседи — по суммам, без сумм — по направлению листа (шапка → пары по суммам → однозначные блоки); неоднозначное — строка `OTHER`, `needs_check`; ведущий ноль числовой строки ИНН (#34). Уточнения владельца (H-1): строка из 10/12 цифр с неверной контрольной суммой не склеивается, остаётся обычной строкой, предупреждение «похожа на ИНН …», `needs_check` (синтетика `s28`); (H-2): если пара найдена, но раскладка противоречит направлению подсказки `<группировка>.ИНН`, строка склеивается, но выдаётся отдельное предупреждение «стоит под/над наименованием … а шапка указывает над/под — проверьте» и `needs_check`. `05-domain-rules.md` §1.3; **реализовано** RSHB/arm-tabular-parser#35 |
| OQ-164 | Субсчета 76 с налоговой природой в ВГО: исключать ли **76.АВ** «НДС по авансам и предоплатам» из ДЗ и зеркально **76.ВА** «НДС по авансам выданным» из КЗ (правила № 5–8 и результирующая таблица §6: «76 кроме 76.07.5/76.07.9»). В ОСВ Счастливочной на 30.09.2024 на 76.АВ — 2 366 666,69 с контрагентом АО «Русский бисквит»: результирующая таблица даёт ДЗ 110 429 113,15, эталон (`fixtures/golden/Консолидация_Группа РБ.xlsx`, лист `01.10.2024`, строка 1230 «в т. ч. взаимные») — 108 062 тыс., ровно без 76.АВ (107 712 446,46 + 350 000 по 76.06 = 108 062 446,46). Есть ли ещё субсчета 76, которые нужно исключать | ревью RSHB/arm-core#114 (шахматка, AURORA-1537), 15.10.2026; вопрос аналитику — AURORA-1373 | `elimination_rule` (Прил. 3, данные), §4, §6; `RESULT_ARTICLES` шахматки; сверка с эталоном (OQ-155) | открыт: до ответа аналитика/методологов расчёт по §4/§6 как есть (76.АВ входит в ДЗ); исключение — правка правил-данных и §4/§6, не кода шахматки |
| OQ-165 | Склейка двух половинок одной формы из **одного** файла (Р-15): Excel с листом-продолжением другого типа, двойная печать формы, две таблицы ИС одного типа и периода. Сейчас вторая форма того же типа и периода хранится отдельным документом (Р-10), ни одна не автоподтверждается (Р-18), подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM`; склейки нет — файл перезагружают. Нужна ли склейка (кнопка «объединить формы») и по каким правилам | change `unified-statement-normalization`, Р-15, Р-18 | arm-core (`document.file_ordinal`, подтверждение), arm-web, §9.5 п. 2 | открыт: от ИС Сканирование такой случай не ожидается (владелец 17.10.2026), основной источник — Excel; допущение — склейки нет, файл перезагружают |
| OQ-166 | Упрощённая форма ББ/ОФР (приказ 66н, прил. 5): признак — по структуре кодов (ББ: есть 1600, нет 1100 и 1200; ОФР: есть 2400 или 2110, нет 2100, 2200, 2300), ОКУД 0710096 признаком не служит. Набор соотношений §7 и перечень кодов упрощённой формы заданы по бланку (`05-domain-rules.md` §7, решение S-5 спеки) без образца в `fixtures/` | change `unified-statement-normalization`, design (Decisions № 14) | `arm_statements` `statement_checks.yaml`, перечень кодов, `form_variant` | открыт: допущение — формулы §7 «Упрощённый набор»; нужен реальный образец упрощённой отчётности (Excel и скан) в `fixtures/` для проверки признака и формул |
| OQ-166 | Упрощённая форма ББ/ОФР (приказ 66н, прил. 5): признак — по структуре кодов (ББ: есть 1600, нет 1100 и 1200; ОФР: есть 2400 или 2110, нет 2100, 2200, 2300), ОКУД 0710096 признаком не служит. Набор соотношений §7 и перечень кодов упрощённой формы заданы по бланку (`05-domain-rules.md` §7, решение S-5 спеки; ББ — жёсткие, ОФР `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` — мягкое, как `2400` полного набора, решение владельца 18.10.2026, Р-22; перечни — `contracts/statement-code-orders.yaml`, у `fsbu4` — как у 66н) без образца в `fixtures/` | change `unified-statement-normalization`, design (Decisions № 14) | `arm_statements` `statement_checks.yaml`, перечень кодов, `form_variant` | открыт: допущение — формулы §7 «Упрощённый набор»; нужен реальный образец упрощённой отчётности (Excel и скан) в `fixtures/` для проверки признака и формул |
| OQ-167 | Несколько отчётных периодов в одном файле, отданном в ИС Сканирование (например, ББ за два квартала в одном PDF): шапка в json ИС одна на json и копируется во все его таблицы | change `unified-statement-normalization`, design шаг 1 (допущение владельца 17.10.2026) | scan-gateway (адаптер шага 1), период документа | допущение владельца: ИС отдаёт разные периоды разными json; образца нет — проверить на первом таком файле, при расхождении — шапка из json неверна для части таблиц |
Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения;
`DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4.
| OQ-168 | Коды упорядоченного перечня форм без образца в `fixtures/` (`contracts/statement-code-orders.yaml`, §11.9): в перечень вошли только коды шаблонов банка и коды печатных форм 1С и сканов, найденные в `fixtures/` (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930, повторы 1220 / 1520 / 2410 у `fsbu4`, раздел III НКО у `66n`). Не подтверждены образцом: 1120 в `fsbu4`; 1105, 1215, 2420 в `66n`; 2421, 2430, 2450 (прежние строки ОФР 66н); 2930 и раздел III НКО в `fsbu4`; клиентский 1231 в `fsbu4`; упрощённые перечни обеих редакций (OQ-166). Добавлять ли их без образца | change `unified-statement-normalization`, ревью спеки K-5 (18.10.2026) | `arm_statements` (перечень кодов, структурное подтверждение неуверенного кода), ядро (`CODE_NOT_IN_EDITION`) | открыт: допущение — код без образца в перечень не входит; на скане такой неуверенный код не подтверждается структурой (`needs_review`, `needs_check`), у Excel и уверенного кода — только `info` ядра `CODE_NOT_IN_EDITION`. Пополнение — строкой в yaml с источником (файл fixtures) |

View File

@ -22,33 +22,34 @@
"units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100,
"okud": "0710001", "okud_text": "0710001", "okud_confidence": 100,
"okpo": "12345678", "okpo_text": "12345678", "okpo_confidence": 100,
"okei": "384", "okei_text": "384", "okei_confidence": 100,
"needs_review_fields": []
},
"columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" },
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" }
{ "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" },
{ "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" }
],
"lines": [
{ "row_index": 0, "ref": "Бухгалтерский баланс. Лист 1!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 1, "ref": "Бухгалтерский баланс. Лист 1!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 2, "ref": "Бухгалтерский баланс. Лист 1!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 3, "ref": "Бухгалтерский баланс. Лист 1!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 4, "ref": "Бухгалтерский баланс. Лист 1!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 5, "ref": "Бухгалтерский баланс. Лист 2!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 6, "ref": "Бухгалтерский баланс. Лист 2!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 7, "ref": "Бухгалтерский баланс. Лист 2!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 1, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
{ "row_index": 0, "ref": "'Бухгалтерский баланс. Лист 1'!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 1, "ref": "'Бухгалтерский баланс. Лист 1'!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 2, "ref": "'Бухгалтерский баланс. Лист 1'!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 3, "ref": "'Бухгалтерский баланс. Лист 1'!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 4, "ref": "'Бухгалтерский баланс. Лист 1'!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 5, "ref": "'Бухгалтерский баланс. Лист 2'!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 6, "ref": "'Бухгалтерский баланс. Лист 2'!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 7, "ref": "'Бухгалтерский баланс. Лист 2'!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
],
"checks": {
"relations_failed": [],
"soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ]
"soft_relations_failed": [ { "column_index": 4, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ]
}
},
{
@ -69,20 +70,20 @@
"needs_review_fields": []
},
"columns": [
{ "column_index": 0, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
{ "column_index": 3, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
],
"lines": [
{ "row_index": 0, "ref": "Отчет о финансовых результатах!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 1, "ref": "Отчет о финансовых результатах!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "Отчет о финансовых результатах!C12", "needs_review": false,
"values": [ { "column_index": 0, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 2, "ref": "Отчет о финансовых результатах!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 3, "ref": "Отчет о финансовых результатах!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] }
{ "row_index": 0, "ref": "'Отчет о финансовых результатах'!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 1, "ref": "'Отчет о финансовых результатах'!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "'Отчет о финансовых результатах'!C12", "needs_review": false,
"values": [ { "column_index": 3, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 2, "ref": "'Отчет о финансовых результатах'!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 3, "ref": "'Отчет о финансовых результатах'!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] }
],
"checks": {
"relations_failed": [ { "column_index": 0, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ],
"relations_failed": [ { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ],
"soft_relations_failed": []
}
},

View File

@ -25,25 +25,25 @@
"needs_review_fields": []
},
"columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
{ "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
],
"lines": [
{ "row_index": 0, "ref": "row:1", "line_status": "ok", "line_code": "1210", "line_code_text": "1210", "line_code_confidence": 100, "line_name": "Запасы", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
"values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 1, "ref": "row:2", "line_status": "ok", "line_code": "1230", "line_code_text": "1230", "line_code_confidence": 100, "line_name": "Дебиторская задолжен-ность", "line_name_confidence": 80, "needs_review": false,
"values": [ { "column_index": 0, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
"values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
{ "row_index": 2, "ref": "row:3", "line_status": "unreadable", "line_code": null, "line_code_text": "ИЧ,250", "line_code_confidence": 25, "line_name": "Финансовые вложения", "line_name_confidence": 67, "needs_review": false,
"values": [ { "column_index": 0, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] },
"values": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] },
{ "row_index": 3, "ref": "row:4", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 4, "ref": "row:5", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 5, "ref": "row:14#1", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
{ "row_index": 6, "ref": "row:14#2", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
{ "row_index": 7, "ref": "row:15", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 0, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
],
"checks": { "relations_failed": [], "soft_relations_failed": [] }
}

View File

@ -0,0 +1,12 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" },
"status": "error",
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet"
},
"documents": []
}

View File

@ -0,0 +1,21 @@
{
"schema_version": "1.0",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"status": "done",
"service": { "name": "scan-gateway", "version": "0.1.0" },
"result_s3": {
"bucket": "arm-ka",
"key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json"
},
"result_schema_version": "1.0",
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
"metrics": {
"duration_ms": 18400,
"pages": 2,
"fields_total": 4,
"fields_low_confidence": 0,
"fields_unrecognized": 0,
"warnings": []
}
}

View File

@ -0,0 +1,11 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"status": "error",
"service": { "name": "tabular-parser", "version": "0.1.0" },
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet"
}
}

View File

@ -0,0 +1,7 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"status": "processing",
"service": { "name": "tabular-parser", "version": "0.1.0" }
}

View File

@ -0,0 +1,83 @@
{
"schema_version": "1.2",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" },
"status": "done",
"documents": [
{
"document_type": "OSV",
"locator": "sheet:Лист_1",
"header": {
"organization_name": "ООО Ромашка",
"organization_inn": "7700000000",
"account_code_raw": "60",
"period_text": "1 квартал 2025 г.",
"period_year": 2025,
"period_quarter": "Q1",
"is_cumulative": false,
"period_start": "2025-01-01",
"period_end": "2025-03-31",
"is_from_year_start": true,
"data_type": "BU",
"currency": "RUB",
"header_variant": 1
},
"group_schema": [
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
],
"columns": {
"opening_debit": "C",
"opening_credit": "D",
"turnover_debit": "E",
"turnover_credit": "F",
"closing_debit": "G",
"closing_credit": "H"
},
"rows": [
{
"row_number": 1,
"level": 0,
"parent_row_number": null,
"row_value": "60",
"group_type": "ACCOUNT",
"group_name": null,
"account_code_raw": "60",
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50"
},
{
"row_number": 2,
"level": 1,
"parent_row_number": 1,
"row_value": "ООО Лютик, ИНН 7711111111",
"group_type": "COUNTERPARTY",
"group_name": "Контрагенты",
"account_code_raw": "60",
"counterparty_name": "ООО Лютик",
"counterparty_inn": "7711111111",
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50",
"extra": { "КПП": "352801001" }
}
],
"checks": {
"total_row_matches_root": true,
"balance_identity_violations": [],
"parent_sum_violations": []
}
}
]
}

View File

@ -0,0 +1,125 @@
{
"schema_version": "1.1",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "1.0.0" },
"status": "needs_check",
"metrics": {
"rows": 3,
"warnings": [
"sheet=Лист_1 hierarchy=outline",
"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3"
]
},
"documents": [
{
"document_type": "OSV",
"locator": "sheet:Лист_1",
"header": {
"organization_name": "ООО Ромашка",
"organization_inn": null,
"account_code_raw": "60",
"period_text": "1 квартал 2025 г.",
"period_year": 2025,
"period_quarter": "Q1",
"is_cumulative": false,
"data_type": "BU",
"currency": "RUB",
"header_variant": 1
},
"group_schema": [
{ "type": "ACCOUNT", "name": "Счет" },
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
],
"columns": {
"opening_debit": "D",
"opening_credit": "E",
"turnover_debit": "F",
"turnover_credit": "G",
"closing_debit": "H",
"closing_credit": "I",
"inn": "B"
},
"rows": [
{
"row_number": 1,
"level": 0,
"parent_row_number": null,
"row_value": "60",
"group_type": "ACCOUNT",
"group_name": "Счет",
"account_code_raw": "60",
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50"
},
{
"row_number": 2,
"level": 1,
"parent_row_number": 1,
"row_value": "ООО Лютик",
"group_type": "COUNTERPARTY",
"group_name": "Контрагенты",
"account_code_raw": "60",
"counterparty_name": "ООО Лютик",
"counterparty_inn": "7711111111",
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50",
"extra": { "КПП": "352801001" }
},
{
"row_number": 3,
"level": 2,
"parent_row_number": 2,
"row_value": "Вал.",
"group_type": "OTHER",
"group_name": null,
"account_code_raw": "60",
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "CURRENCY",
"opening_debit": "0.00",
"opening_credit": "400.00",
"turnover_debit": "100.00",
"turnover_credit": "50.00",
"closing_debit": "0.00",
"closing_credit": "400.00"
},
{
"row_number": 4,
"level": 0,
"parent_row_number": null,
"row_value": "Итого",
"group_type": "TOTAL",
"group_name": null,
"account_code_raw": null,
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50"
}
],
"checks": {
"total_row_matches_root": true,
"balance_identity_violations": [3],
"parent_sum_violations": []
}
}
]
}

View File

@ -0,0 +1,46 @@
{
"schema_version": "1.2",
"job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30",
"file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"service": { "name": "tabular-parser", "version": "0.3.0" },
"status": "done",
"metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] },
"documents": [
{
"document_type": "BALANCE",
"locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2",
"header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" },
"columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" },
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" }
],
"lines": [
{ "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] },
{ "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] },
{ "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] },
{ "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] },
{ "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }
],
"checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] }
},
{
"document_type": "INCOME",
"locator": "sheet:Отчет о финансовых результатах",
"header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" },
"columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ],
"lines": [
{ "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] },
{ "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] },
{ "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] }
],
"checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] }
},
{
"document_type": "DISCLOSURE",
"locator": "sheet:Пояснения",
"header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" },
"columns": [],
"lines": []
}
]
}

View File

@ -0,0 +1,50 @@
{
"schema_version": "1.2",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"service": { "name": "scan-gateway", "version": "0.1.0" },
"status": "done",
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
"documents": [
{
"document_type": "BALANCE",
"locator": "pages:1-2",
"header": {
"form_name": "Бухгалтерский баланс",
"organization_name": "ООО Ромашка",
"organization_name_confidence": 97.5,
"inn": "7700000000",
"inn_confidence": 99,
"report_date_text": "30 сентября 2025 г.",
"report_date": "2025-09-30",
"units": "тыс. руб."
},
"columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" },
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" }
],
"lines": [
{
"line_code": "1100",
"line_code_confidence": 99,
"line_name": "Итого по разделу I",
"line_name_confidence": 96,
"values": [
{ "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true },
{ "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true }
]
},
{
"line_code": "1600",
"line_code_confidence": 99,
"line_name": "БАЛАНС (актив)",
"line_name_confidence": 98,
"values": [
{ "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true },
{ "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true }
]
}
]
}
]
}

View File

@ -3,7 +3,7 @@
"$id": "arm-ka/recognition-result-message/v2",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v2.0",
"description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата. Схема 1.0 на окне перехода лежит рядом — recognition-result-message.v1.schema.json (примеры — examples/v1/).",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "status", "service"],
"properties": {
@ -61,7 +61,7 @@
"message": { "type": "string" },
"line_refs": { "type": "array", "items": { "type": "string" } },
"column_index": { "type": "integer" },
"field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "address"] },
"field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] },
"locator": { "type": "string" }
}
}

View File

@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result-message/v1",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
"description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "status", "service"],
"properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" },
"job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" },
"file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" },
"status": { "enum": ["processing", "done", "needs_check", "error"] },
"service": {
"type": "object",
"required": ["name", "version"],
"properties": {
"name": { "enum": ["tabular-parser", "scan-gateway"] },
"version": { "type": "string" }
}
},
"result_s3": {
"type": "object",
"description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json",
"required": ["bucket", "key"],
"properties": {
"bucket": { "type": "string", "minLength": 1 },
"key": { "type": "string", "minLength": 1 }
}
},
"result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" },
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] },
"message": { "type": "string" }
}
},
"raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" },
"metrics": {
"type": "object",
"properties": {
"duration_ms": { "type": "integer" },
"pages": { "type": "integer" },
"rows": { "type": "integer" },
"fields_total": { "type": "integer" },
"fields_low_confidence": { "type": "integer" },
"fields_unrecognized": { "type": "integer" },
"warnings": { "type": "array", "items": { "type": "string" } }
}
}
},
"allOf": [
{
"if": {
"properties": { "status": { "enum": ["done", "needs_check"] } },
"required": ["status"]
},
"then": { "required": ["result_s3", "result_schema_version"] }
},
{
"if": {
"properties": { "status": { "const": "error" } },
"required": ["status"]
},
"then": { "required": ["error"] }
}
]
}

View File

@ -3,7 +3,7 @@
"$id": "arm-ka/recognition-result/v2",
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0",
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, смысл is_recognized у OCR отвязан от confidence. Схема 1.2 — в истории git (коммит 43b4b80 репозитория arm-ka-docs) и в моделях маппера 1.x ядра.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, header.okei (+ _text, _confidence), смысл is_recognized у OCR отвязан от confidence. Схема 1.2 на окне перехода лежит рядом — recognition-result.v1.schema.json (примеры — examples/v1/); удаляется вместе с маппером 1.x ядра после перехода обоих сервисов.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "service", "status", "documents"],
"properties": {
@ -69,7 +69,7 @@
"money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" },
"confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"documentStatus": { "enum": ["done", "needs_check"] },
"headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "address"] },
"headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] },
"warning": {
"type": "object",
"required": ["code", "severity", "message"],
@ -77,8 +77,8 @@
"code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$", "examples": ["COLUMN_WITHOUT_DATE", "ROW_SPLIT"], "description": "код по таблице 05-domain-rules.md §11; не перечисление — новый код не меняет MAJOR" },
"severity": { "enum": ["error", "info"], "description": "error → документ needs_check; info на статус не влияет" },
"message": { "type": "string" },
"line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа (statementDocument.lines[].ref)" },
"column_index": { "type": "integer" },
"line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа — только строк из statementDocument.lines[]; строки ролей title / header / numbering в line_refs не попадают (о них — message)" },
"column_index": { "type": "integer", "description": "столбец сетки кандидата (как columns[].column_index); у COLUMN_SKIPPED и других предупреждений о столбце вне columns[] — номер этого столбца" },
"field": { "$ref": "#/$defs/headerField" }
}
},
@ -105,7 +105,7 @@
"properties": {
"document_type": { "const": "OSV" },
"locator": { "type": "string", "examples": ["sheet:Лист_1"] },
"status": { "$ref": "#/$defs/documentStatus", "description": "done — ведомость разобрана (§1.6)" },
"status": { "$ref": "#/$defs/documentStatus", "description": "done — контрольные суммы §1.4 листа сошлись, иначе needs_check (05-domain-rules.md §1.6)" },
"warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" }, "description": "предупреждения листа ОСВ; все уровня info, код с префиксом OSV_" },
"header": {
"type": "object",
@ -184,7 +184,7 @@
"type": "object",
"required": ["column_index", "amount", "text", "confidence", "is_recognized", "needs_review"],
"properties": {
"column_index": { "type": "integer" },
"column_index": { "type": "integer", "description": "колонка значений — columns[].column_index" },
"amount": { "$ref": "#/$defs/money" },
"string_value": { "type": ["string", "null"], "description": "текст ячейки, только когда amount = null и текст непуст и не прочерк" },
"text": { "type": ["string", "null"], "description": "сырой текст источника всегда (у Excel — как показывает числовой формат); null — позиции нет" },
@ -198,7 +198,7 @@
"required": ["row_index", "ref", "line_status", "line_code", "line_code_text", "line_name", "needs_review", "values"],
"properties": {
"row_index": { "type": "integer", "minimum": 0, "description": "порядковый номер строки в документе с 0, по порядку сетки после разлепления" },
"ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27"], "description": "положение в источнике; уникален в документе" },
"ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27", "'Бухгалтерский баланс. Лист 1'!C14"], "description": "положение в источнике; уникален в документе. OCR — row:<row_index ИС>[#k]; Excel — <лист>!<A1> по правилу Excel: имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф внутри имени удваивается" },
"line_status": { "enum": ["ok", "unreadable", "missing", "detail"] },
"line_code": { "type": ["string", "null"], "pattern": "^\\d{4,5}$", "description": "разобранный код; null ⇔ line_status ≠ ok" },
"line_code_text": { "type": ["string", "null"], "description": "сырой текст ячейки кода" },
@ -258,10 +258,13 @@
"okfs": { "type": ["string", "null"] },
"okfs_text": { "type": ["string", "null"] },
"okfs_confidence": { "$ref": "#/$defs/confidence" },
"okei": { "type": ["string", "null"], "examples": ["384"], "description": "код ОКЕИ единицы (384 — тыс. руб., 383 — руб., 385 — млн руб.); запасной источник units (§11.2)" },
"okei_text": { "type": ["string", "null"] },
"okei_confidence": { "$ref": "#/$defs/confidence" },
"address": { "type": ["string", "null"] },
"address_text": { "type": ["string", "null"] },
"address_confidence": { "$ref": "#/$defs/confidence" },
"needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки с needs_review = true (сейчас — только report_date)" }
"needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки, значение которых требует проверки и держит документ в needs_check (сейчас — только неуверенная дата отчёта, не подтверждённая датами колонок); сомнения в ИНН и организации — предупреждения info с field, сюда не входят (§11.9)" }
}
},
"columns": {
@ -270,7 +273,7 @@
"type": "object",
"required": ["column_index", "label", "period_kind"],
"properties": {
"column_index": { "type": "integer" },
"column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" },
"label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] },
"label_confidence": { "$ref": "#/$defs/confidence", "description": "у Excel 100; подписи нет — null" },
"period_date": { "type": ["string", "null"], "format": "date", "description": "конец периода колонки (period_end); у относительной подписи — от эффективной даты документа" },
@ -278,7 +281,7 @@
}
}
},
"lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" } },
"lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" },
"checks": {
"type": "object",
"required": ["relations_failed", "soft_relations_failed"],

View File

@ -0,0 +1,220 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result/v1",
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2",
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "service", "status", "documents"],
"properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" },
"job_id": { "type": "string", "format": "uuid" },
"file_id": { "type": "string", "format": "uuid" },
"service": {
"type": "object",
"required": ["name", "version"],
"properties": {
"name": { "enum": ["tabular-parser", "scan-gateway"] },
"version": { "type": "string" }
}
},
"status": { "enum": ["done", "needs_check", "error"] },
"error": {
"type": ["object", "null"],
"properties": {
"code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] },
"message": { "type": "string" }
}
},
"metrics": {
"type": "object",
"properties": {
"duration_ms": { "type": "integer" },
"pages": { "type": "integer" },
"rows": { "type": "integer" },
"fields_total": { "type": "integer" },
"fields_low_confidence": { "type": "integer" },
"fields_unrecognized": { "type": "integer" },
"warnings": { "type": "array", "items": { "type": "string" } }
}
},
"raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" },
"documents": {
"type": "array",
"items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] }
}
},
"if": {
"properties": { "status": { "enum": ["done", "needs_check"] } },
"required": ["status"]
},
"then": {
"properties": { "documents": { "minItems": 1 } }
},
"$defs": {
"money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" },
"failedRelation": {
"type": "object",
"required": ["column_index", "relation", "expected", "actual"],
"properties": {
"column_index": { "type": "integer" },
"relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] },
"expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" },
"actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" }
}
},
"osvDocument": {
"type": "object",
"required": ["document_type", "locator", "header", "group_schema", "rows"],
"properties": {
"document_type": { "const": "OSV" },
"locator": { "type": "string", "examples": ["sheet:Лист_1"] },
"header": {
"type": "object",
"required": ["organization_name", "account_code_raw", "period_text"],
"properties": {
"organization_name": { "type": "string", "description": "как в r1 файла" },
"organization_inn": { "type": ["string", "null"] },
"account_code_raw": { "type": "string", "examples": ["60", "58.03"] },
"period_text": { "type": "string", "examples": ["1 квартал 2025 г.", "2024 г."] },
"period_year": { "type": ["integer", "null"] },
"period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] },
"is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" },
"period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" },
"period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" },
"is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" },
"data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] },
"currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] },
"header_variant": { "enum": [1, 2] }
}
},
"group_schema": {
"type": "array",
"description": "порядок группировок из шапки, после первого ACCOUNT",
"items": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] },
"name": { "type": "string", "description": "исходный текст из шапки" },
"has_inn": { "type": "boolean" }
}
}
},
"columns": {
"type": "object",
"description": "диагностика: какие колонки листа были приняты за шесть сумм и ИНН",
"additionalProperties": { "type": "string" }
},
"rows": {
"type": "array",
"items": {
"type": "object",
"required": ["row_number", "level", "row_value", "group_type"],
"properties": {
"row_number": { "type": "integer" },
"level": { "type": "integer" },
"parent_row_number": { "type": ["integer", "null"] },
"row_value": { "type": "string" },
"group_type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] },
"group_name": { "type": ["string", "null"] },
"account_code_raw": { "type": ["string", "null"], "description": "код текущей ветки, как в файле (60.1)" },
"counterparty_name": { "type": ["string", "null"] },
"counterparty_inn": { "type": ["string", "null"] },
"data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] },
"opening_debit": { "$ref": "#/$defs/money" },
"opening_credit": { "$ref": "#/$defs/money" },
"turnover_debit": { "$ref": "#/$defs/money" },
"turnover_credit": { "$ref": "#/$defs/money" },
"closing_debit": { "$ref": "#/$defs/money" },
"closing_credit": { "$ref": "#/$defs/money" },
"extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } }
}
}
},
"checks": {
"type": "object",
"properties": {
"total_row_matches_root": { "type": "boolean" },
"balance_identity_violations": { "type": "array", "items": { "type": "integer" }, "description": "row_number строк, где opening−turnover≠closing" },
"parent_sum_violations": { "type": "array", "items": { "type": "integer" } }
}
}
}
},
"statementDocument": {
"type": "object",
"required": ["document_type", "locator", "header", "columns", "lines"],
"properties": {
"document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] },
"locator": { "type": "string", "examples": ["pages:1-2"] },
"header": {
"type": "object",
"properties": {
"form_name": { "type": "string" },
"organization_name": { "type": ["string", "null"] },
"organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"inn": { "type": ["string", "null"] },
"inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"report_date_text": { "type": ["string", "null"] },
"report_date": { "type": ["string", "null"], "format": "date" },
"units": { "type": ["string", "null"], "examples": ["тыс. руб."] },
"okud": { "type": ["string", "null"] },
"okpo": { "type": ["string", "null"] },
"okopf": { "type": ["string", "null"] },
"okfs": { "type": ["string", "null"] },
"address": { "type": ["string", "null"] }
}
},
"columns": {
"type": "array",
"items": {
"type": "object",
"required": ["column_index", "label"],
"properties": {
"column_index": { "type": "integer" },
"label": { "type": "string", "examples": ["На 30 сентября 2025 г."] },
"period_date": { "type": ["string", "null"], "format": "date" }
}
}
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["line_code", "line_name", "values"],
"properties": {
"line_code": { "type": "string" },
"line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"line_name": { "type": "string" },
"line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"values": {
"type": "array",
"items": {
"type": "object",
"required": ["column_index"],
"properties": {
"column_index": { "type": "integer" },
"amount": { "$ref": "#/$defs/money" },
"string_value": { "type": ["string", "null"] },
"confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"is_recognized": { "type": "boolean" }
}
}
}
}
}
},
"checks": {
"type": "object",
"description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».",
"properties": {
"relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" },
"soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" },
"duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" }
}
}
}
}
}
}

View File

@ -0,0 +1,193 @@
# Упорядоченные перечни кодов строк форм ББ/ОФР по редакциям и вариантам — данные для структурного подтверждения
# неуверенного кода (05-domain-rules.md §11.9 «Код строки»; change unified-statement-normalization, K-5, Р-20).
#
# Что это: порядок кодов бланка (итоги после слагаемых: 1110 … 1190, 1100), без наименований (Р-3: «код → наименование»
# знает только ядро, arm-core/api/rules/statement_lines.yaml). Только четырёхзначные коды: пятизначная подстрока
# подтверждается через родителя (§11.9). Перечень — объединение кодов шаблонов банка (словарь имён ядра,
# fixtures/statements-xlsx/templates/) и кодов печатных форм 1С и сканов, найденных в fixtures (источник — комментарий
# у кода). Код без образца в fixtures сюда не добавляется: кандидаты — OQ-168.
#
# Где живёт: этот файл — источник; в пакете arm_statements лежит его байтовая копия (rules/statement_code_orders.yaml),
# идентичность проверяет sync-statements --check (как копии схем контракта в сервисах). Ядро берёт порядок и состав
# редакций из копии пакета; sync-statements --check сверяет, что все коды словаря имён ядра есть в перечне своей
# редакции. Правка перечня — PR сюда, затем в arm-statements, затем sync-statements в потребителях («сначала ядро»).
#
# Как читается (§11.9): четырёхзначные коды строк ok документа — последовательность; для каждой редакции берётся
# наибольшая подпоследовательность, согласованная с порядком перечня варианта формы документа (form_variant); выбирается
# редакция с более длинной (при равенстве — по эффективной дате: fsbu4 после 2024-12-31). Повтор кода допустим только
# там, где он есть в перечне (repeats): тогда структура подтверждает обе копии; повтор вне перечня с неуверенной копией —
# needs_review у неуверенной копии (Р-16, Р-20).
#
# Проверка формы файла — scripts/validate_contracts.py (make validate-contracts).
version: 1
# Законные повторы кода в одном перечне (Р-20). Ключ — редакция и форма; значение — коды, которые встречаются в
# перечне больше одного раза.
repeats:
66n:
# раздел III для некоммерческих организаций («Целевое финансирование») печатается после раздела III коммерческих
# (fixtures/ocr/aep_3.json, 31.12.2024: 1310 «Паевой фонд», 1320 «Целевой капитал», 1350 «Целевые средства»,
# 1360 «Фонд недвижимого и особо ценного движимого имущества», 1370 «Резервный и иные целевые фонды»)
BALANCE: ["1310", "1320", "1350", "1360", "1370", "1300"]
INCOME: []
fsbu4:
# печатная форма 1С по ФСБУ 4/2023 (fixtures/ocr/aep_6.json, «БО 1 кв 2025.json», «Бухгалтерская отчетность
# 1 кв.25.json»): 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде» (AURORA-1431)
BALANCE: ["1220", "1520"]
# 2410 «Налог при упрощенной системе налогообложения» после 2412 (fixtures/ocr/aep_6.json, OQ-152)
INCOME: ["2410"]
editions:
66n:
BALANCE:
full:
- "1110"
- "1120"
- "1130"
- "1140"
- "1150"
- "1160"
- "1170"
- "1180"
- "1190"
- "1100"
- "1210"
- "1220"
- "1230"
- "1231" # код клиента: unagrande-group/2024-FY/ББ. Лист 1 .xls
- "1240"
- "1250"
- "1260"
- "1200"
- "1600"
- "1310"
- "1320"
- "1340"
- "1350"
- "1360"
- "1370"
- "1300"
- "1310" # раздел III НКО (aep_3), см. repeats
- "1320"
- "1350"
- "1360"
- "1370"
- "1300"
- "1410"
- "1420"
- "1430"
- "1450"
- "1400"
- "1510"
- "1520"
- "1530"
- "1540"
- "1550"
- "1500"
- "1700"
# упрощённая форма, приказ 66н прил. 5 — по бланку, образца в fixtures нет (OQ-166)
simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510",
"1520", "1550", "1700"]
INCOME:
full:
- "2110"
- "2120"
- "2100"
- "2210"
- "2220"
- "2200"
- "2310"
- "2320"
- "2330"
- "2340"
- "2350"
- "2300"
- "2410"
- "2411"
- "2412"
- "2460"
- "2400"
- "2510"
- "2520"
- "2530" # печатная форма 1С: unagrande-co/2024-FY/ОФР. Лист 2 .xls, mezheninovskaya/2025-Q3
- "2500"
- "2900"
- "2910"
- "2930" # печатная форма 1С: fixtures/ocr/aep_3.json (после 2900)
# упрощённая форма, приказ 66н прил. 5 — по бланку, образца нет (OQ-166)
simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"]
fsbu4:
BALANCE:
full:
- "1105" # печатная форма 1С: npz/2025-Q3, fixtures/ocr/aep_6.json, «БО 1 кв 2025.json»
- "1110"
- "1130" # печатная форма 1С: npz/2025-Q3, «БО 1 кв 2025.json», «Бухгалтерская отчетность 1 кв.25.json»
- "1140" # то же и aep_6.json
- "1150"
- "1160"
- "1170"
- "1180"
- "1190"
- "1100"
- "1210"
- "1215"
- "1220"
- "1220" # «НДС, начисленный по отгрузке», см. repeats
- "1230"
- "1240"
- "1250"
- "1260"
- "1200"
- "1600"
- "1310"
- "1320"
- "1340"
- "1350"
- "1360"
- "1370"
- "1300"
- "1410"
- "1420"
- "1430"
- "1450"
- "1400"
- "1510"
- "1520"
- "1520" # «Обязательство по аренде», см. repeats
- "1530" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025
- "1540"
- "1550"
- "1500"
- "1700"
# упрощённая форма по ФСБУ 4/2023 — состав принят как у 66н до образца (OQ-166, OQ-168)
simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510",
"1520", "1550", "1700"]
INCOME:
full:
- "2110"
- "2120"
- "2100"
- "2210"
- "2220"
- "2200"
- "2310"
- "2320"
- "2330"
- "2340"
- "2350"
- "2300"
- "2410"
- "2411"
- "2412"
- "2410" # «Налог при упрощенной системе налогообложения», см. repeats
- "2420"
- "2460" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025
- "2400"
- "2510"
- "2520"
- "2530"
- "2500"
- "2900"
- "2910"
# упрощённая форма по ФСБУ 4/2023 — как у 66н до образца (OQ-166, OQ-168)
simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"]

View File

@ -21,7 +21,7 @@
| `make test` | `pytest -m "not integration"`: юнит-тесты; **включает тест идентичности вендоренного контракта** `api/contracts/` с `../docs/contracts` | нет |
| `make test-integration` | `pytest -m integration` против инфраструктуры compose (PostgreSQL 16, NATS, MinIO; бакет `arm-ka` должен существовать, его создаёт `make up`) | да, `make up` |
| `make check` | `make lint` затем `make test`; останавливается на первой упавшей проверке (только при последовательном запуске: `lint` и `test` — пререквизиты, при `make -j` они идут параллельно) | нет |
| `make check-full` | `make check` + `make test-integration` + сборка docker-образа (`docker build`); для `arm-core` — расширенный набор, см. ниже | да, `make up` и Docker |
| `make check-full` | `make check` + `make test-integration` + сборка docker-образа (`docker build`) + `statements-check` (свежесть копии общего кода `arm_statements`, см. ниже); для `arm-core` — расширенный набор, см. ниже | да, `make up` и Docker |
| `make up` | поднимает инфраструктуру из `../docs/dev/docker-compose.yml`; постоянные сервисы `postgres`, `nats`, `minio` | Docker |
Сводка шагов `make check`: `ruff check .` → `ruff format --check .` → `mypy src` (в `arm-core` — `mypy src scripts`) →
@ -51,9 +51,45 @@ docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm
| Репозиторий | Шаги по порядку |
|---|---|
| `scan-gateway` | `check` → `test-integration` → `docker-build` (образ `scan-gateway:dev`); интеграционный тест поднимает эмулятор ИС Сканирование в процессе теста |
| `tabular-parser` | `check` → `test-integration` → `docker-build` (образ `tabular-parser`) |
| `arm-core` | `check` → `dbml-check` → `check-infra` → `test-integration` → `docker-build` → `web-check` |
| `scan-gateway` | `check` → `statements-check` → `test-integration` → `docker-build` (образ `scan-gateway:dev`); интеграционный тест поднимает эмулятор ИС Сканирование в процессе теста |
| `tabular-parser` | `check` → `statements-check` → `test-integration` → `docker-build` (образ `tabular-parser`) |
| `arm-core` | `check` → `statements-check` → `dbml-check` → `check-infra` → `test-integration` → `docker-build` → `web-check` |
`statements-check` появляется в сервисе вместе с копией пакета `arm_statements` (change `unified-statement-normalization`,
шаги 3 и 5 выкладки); до этого цели нет.
### Копия общего кода `arm_statements` и `sync-statements`
Общий код нормализации ББ/ОФР (`05-domain-rules.md` §11) живёт в репозитории `arm-statements` пакетом `arm_statements/`;
в `arm-core`, `tabular-parser` и `scan-gateway` он лежит **копией** — прямые правки копии запрещены (Р-1, design
`openspec/changes/unified-statement-normalization/design.md`). **Пока не реализовано:** скрипт, цель `make -C docs
sync-statements` и цель `statements-check` сервисов появляются на шагах 1, 3 и 5 выкладки change
`unified-statement-normalization`; раздел описывает целевое поведение (спека `statement-normalization`, требование
«`sync-statements`…»).
- **Где копия:** `api/arm_statements/` (рядом с `api/src/`; импорт `import arm_statements` — `api/` в `sys.path`, как для
`src`). Пакет приносит свои тесты (`api/arm_statements/tests/`) и конфиг линтеров; `ruff`, `pytest` и `mypy` хоста
охватывают копию: `ruff` и `pytest` — по каталогу `api/` (тесты пакета подключены как `testpaths`), `mypy` — `MYPY_PATHS`
хоста дополняется `arm_statements` (`mypy src arm_statements`, в `arm-core` — `mypy src scripts arm_statements`).
- **Маркер версии:** `api/arm_statements/VERSION` — полный хэш коммита `arm-statements`, из которого разложена копия;
пишет только скрипт. Сервис при старте читает его и пишет в результат `service.statements_version`; ядро сравнивает его
с `statements_version` задания при пересчёте после правки (`STATEMENTS_VERSION_MISMATCH`, `info`).
- **Скрипт:** `docs/scripts/sync-statements.sh` (по образцу `sync-web.sh`), запуск из workspace или `make -C docs
sync-statements SYNC_ARGS=…`. Источник — локальный checkout `arm-statements` рядом (`../arm-statements`) или
`--source <dir>`, ссылка — `origin/main` или `--ref <ref>`; хост — `--host arm-core|tabular-parser|scan-gateway` или
`--host-dir <dir>`. Без `--check` — раскладывает копию нужного коммита в новый worktree хоста на ветке фичи и пишет
`VERSION`; issue и PR — как у `sync-web.sh`.
- **`--check`, коды возврата:** `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, и это коммит `--ref`;
`10` — копия равна дереву коммита из `VERSION`, но в `--ref` есть более новый (копия отстала; предупреждение, не
ошибка); `20` — копия отличается от дерева коммита из `VERSION` (прямая правка) или `VERSION` нет в истории источника,
либо байтовая копия перечня кодов `arm_statements/rules/statement_code_orders.yaml` отличается от
`docs/contracts/statement-code-orders.yaml`, либо (только `arm-core`) код словаря имён `api/rules/statement_lines.yaml`
отсутствует в перечне своей редакции; `2` — нет checkout `arm-statements` или хоста, неизвестный аргумент. Файлы
расхождения перечисляются в выводе.
- **`statements-check` в `make check-full`:** `../docs/scripts/sync-statements.sh --check --host-dir .` (docs — `../docs`
или `ARM_KA_DOCS`); код `0` и `10` — цель зелёная (при `10` предупреждение печатается, в описание PR вписывается, что копия
отстала), `20` и `2` — цель красная. В `make check` и в пайплайн платформы не входит: тест, читающий соседний
репозиторий, уже ломал `main` (arm-core #33).
Для `arm-core` дополнительно:
@ -130,14 +166,18 @@ PR убедитесь, что `docs` и `fixtures` доступны (пропу
| Цель | Что делает |
|---|---|
| `make venv` | `.venv` (Python 3.11, `uv`) с `jsonschema` — единственной зависимостью скриптов |
| `make validate-contracts` | `python scripts/validate_contracts.py`: примеры `contracts/examples/` против трёх схем JSON Schema 2020-12 (3 схемы, 8 примеров); код 1 при любой ошибке |
| `make venv` | `.venv` (Python 3.11, `uv`) с `jsonschema` и `PyYAML` — зависимостями скриптов (после обновления репозитория — перезапустить) |
| `make validate-contracts` | `python scripts/validate_contracts.py`: примеры `contracts/examples/` против текущих схем JSON Schema 2020-12, примеры `contracts/examples/v1/` — против схем прежнего MAJOR `*.v1.schema.json` (окно перехода на контракт 2.0), форма упорядоченных перечней кодов `contracts/statement-code-orders.yaml`; код 1 при любой ошибке |
| `make validate-openspec` | `openspec validate slice-0-skeleton --strict` (другое изменение: `make validate-openspec CHANGE=<id>`) |
| `make check` | обе проверки выше |
Без `make`: `python scripts/validate_contracts.py` (нужен `jsonschema`) и `openspec validate <id> --strict`.
Менять `contracts/**` нужно осознанно: байтовые копии схем лежат в `api/contracts/` каждого сервиса, после правки их
нужно пересинхронизировать (`cp`), иначе тест идентичности в сервисах упадёт.
нужно пересинхронизировать (`cp`), иначе тест идентичности в сервисах упадёт. На окне перехода между MAJOR (сейчас 1 → 2)
тест идентичности сервиса сверяет свою копию со схемой **того MAJOR, который сервис пишет**: копия с `$id` `…/v1` — с
`*.v1.schema.json`, иначе — с основным файлом (`openspec/specs/recognition-contract`, требование «Копия контракта в
сервисе идентична источнику»). Ядро держит копии обоих MAJOR. Перечень кодов `contracts/statement-code-orders.yaml`
копируется не в сервисы, а в пакет `arm_statements` (сверяет `sync-statements --check`).
## Порядок перед PR

View File

@ -422,12 +422,15 @@ UI прячет элементы по `GET /me → permissions` (объедин
**`GET /documents/{id}/statement` — новые поля `StatementOut`:**
- `document.form_variant` — `full` | `simplified` | `null` (упрощённая форма — свой набор проверок §7).
- `header.needs_review_fields[]` — поля шапки, требующие проверки (сейчас бывает только `report_date`); сырые тексты полей.
- `header.needs_review_fields[]` — поля шапки, которые держат документ в «Требует проверки» (сейчас бывает только
`report_date`; подсветить поле). Сомнения в ИНН и организации — не здесь, а в `issues` с `field` (`INN_UNCERTAIN`,
`ORGANIZATION_UNCERTAIN`, уровень `info`). `header.source` — `{<поле>: {text, confidence}}`: как поле распознано
(сырой текст и уверенность) — в подсказку у поля; `header.organization_name_modified` — наименование правилось.
- `columns[].period_kind` — `absolute` | `relative:current` | `relative:prev_year_end` | `relative:prev_prev_year_end` |
`relative:prev_period` | `none`; `columns[].label_confidence`. Колонка с `period_kind = none` и `period_date = null` —
нужна дата от аналитика (ввод даты у колонки).
- `lines[]` — **все строки источника**, а не только строки с кодом: `row_index` (порядок в файле; `null` у шаблонных и
созданных правкой), `source_ref`, `line_status` (`ok` | `unreadable` | `missing` | `detail`), `line_code` (`null` у
- `lines[]` — **все строки источника**, а не только строки с кодом: `row_index` (порядок в файле; `null` — и только
тогда — у строк, созданных правкой, и у достроенных шаблонных; `origin = template` ⇔ `row_index = null`), `source_ref`, `line_status` (`ok` | `unreadable` | `missing` | `detail`), `line_code` (`null` у
статусов кроме `ok`), `line_code_text` (сырой текст ячейки кода — показывать серым у `unreadable` / `missing`),
`line_name` (наименование **из файла**), `reference_name` (справочное имя по коду или `null` — его показывать основным,
файловое рядом, если отличается), `parent_row_index` (родитель расшифровки `detail`), `needs_review` (подсветить строку),
@ -437,37 +440,45 @@ UI прячет элементы по `GET /me → permissions` (объедин
**Подсветка ячейки — только по `needs_review`**; по `confidence < 100` и `is_recognized = false` больше не подсвечивать
(сервис уже свёл их с проверками §7: догадка, подтверждённая сошедшимся соотношением, не подсвечивается).
- `issues[]` — предупреждения и нарушения документа: `{id, kind: "warning" | "relation" | "soft", code, severity: "error" |
"info", message, line_ids[], column_id?, field?, source: "service" | "core", relation?, expected?, actual?}`; показывать у
места (строки, ячейки, поле шапки) или у документа; `error` отличать от `info`.
"info", message, line_ids[], column_id?, column_index?, field?, source: "service" | "core", relation?, codes?, expected?,
actual?}`; показывать у места (строки, ячейки, поле шапки) или у документа; `error` отличать от `info`. `column_index` без
`column_id` — столбец, которого нет среди колонок формы (пропущенная колонка «Изменение, %»): показывать у документа.
Нарушения §7 приходят и здесь (`kind = relation` / `soft`, `code = RELATION_FAILED` / `RELATION_SOFT`) — у любой формы,
в том числе загруженной до перехода.
- `checks.failed[]` / `soft_failed[]` — у нарушения ещё `codes[]` и `line_ids[]` (все строки кодов соотношения).
- Порядок `lines` — уже «как на бумаге»: строки `ok` по коду (одинаковые коды по `row_index`), `unreadable` / `missing` —
после ближайшей предыдущей по `row_index` строки с кодом (без такой — в начале), `detail` — сразу после родителя.
- Порядок `lines` — по возрастанию кода, как сейчас (одинаковые коды — по `row_index`); `unreadable` / `missing` — после
ближайшей предыдущей по `row_index` строки с кодом (без такой — в начале), `detail` — сразу после родителя. Разделы и
размещение итогов «как на бумаге» строит экран, как и раньше (`formLayout`).
Расшифровки (`detail`) по умолчанию скрывать за переключателем «Показать расшифровки (N)».
**`PATCH /documents/{id}/statement` — новые части тела** (можно вместе в одном запросе; один запрос — один пересчёт):
| Часть | Тело | Эффект | Ошибки |
|---|---|---|---|
| подтвердить ячейку | `values: [{line_id, column_id, confirmed: true}]` | `is_confirmed`, `needs_review = false`, сумма не меняется | 422 `UNKNOWN_CELL` |
| дата колонки | `columns: [{column_id, period_date}]` | дата колонки, `is_current`, период документа; снимает `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` | 422 `UNKNOWN_COLUMN` |
| назначить / сменить код | `lines: [{line_id, line_code}]` | `unreadable` / `missing` / `detail` / `ok` → `ok` с этим кодом, `line_code_confidence = 100` | 422 `INVALID_LINE_CODE` (не 4–5 цифр), 409 `LINE_EXISTS` (код занят строкой, созданной правкой, с суммами — сначала очистить её) |
| отметить расшифровкой | `lines: [{line_id, line_status: "detail"}]` | строка выходит из сумм и не блокирует подтверждение | 422 `LINE_CODE_REQUIRED` (`line_status: "ok"` без кода) |
| подтвердить код | `lines: [{line_id, confirmed: true}]` | `is_confirmed`, `needs_review` строки = `false` | — |
| подтвердить ячейку / снять подтверждение | `values: [{line_id, column_id, confirmed: true \| false}]` | `is_confirmed`, `needs_review = false` (при `false` — пересчитывается), сумма не меняется, акцепт не сбрасывается | 422 `UNKNOWN_CELL` |
| дата колонки | `columns: [{column_id, period_date}]` (дата обязательна) | дата колонки, `is_current`, период документа; снимает `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` | 422 `UNKNOWN_COLUMN`; `period_date: null` — 422 массивом |
| назначить / сменить код | `lines: [{line_id, line_code}]` | `unreadable` / `missing` / `detail` / `ok` → `ok` с этим кодом, `line_code_confidence = 100` | 422 `INVALID_LINE_CODE` (не 4–5 цифр), 409 `LINE_CODE_TAKEN` (код занят строкой, созданной правкой, с суммами — сначала очистить её) |
| отметить расшифровкой | `lines: [{line_id, line_status: "detail"}]` | строка выходит из сумм и не блокирует подтверждение; обратно — только назначением кода | 422 `LINE_CODE_REQUIRED` (`line_status: "ok"` — в `ok` переводит только `line_code`) |
| подтвердить код / снять подтверждение | `lines: [{line_id, confirmed: true \| false}]` | `is_confirmed`, `needs_review` строки = `false` (при `false` — пересчитывается) | — |
| наименование организации | `header: {organization_name}` (непустая строка) | `organization_name_modified`, `counterparty_check`; снимает `NO_ORGANIZATION_NO_INN` | 422 массивом |
| подтвердить дату отчёта | `header: {report_date}` тем же значением, если `report_date` в `needs_review_fields` | поле уходит из `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN` | — |
Общие ошибки `lines`: 422 `UNKNOWN_LINE` (строка не этого документа), 422 `INVALID_LINE_PATCH` (в элементе нет действия или
их несколько). Правка суммы по `line_code` (достроенная строка) для кода, у которого уже есть строка `ok`, — 422
`UNKNOWN_CELL`: передайте `line_id`. Правка `header.report_date` пересчитывает даты колонок с `period_kind = relative:*`.
Удалять строки нельзя — для этого «Сделать расшифровкой». Ответ — `StatementOut` после пересчёта.
Удалять строки нельзя — для этого «Сделать расшифровкой». Акцепт проверок сбрасывает правка данных (сумма, код, статус
строки, дата колонки, поле шапки), подтверждения (`confirmed`) его не сбрасывают. Ответ — `StatementOut` после пересчёта.
**`POST /documents/{id}/confirm` для формы — порядок 409:** `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` →
`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → **`LINES_UNRESOLVED`** (`detail.lines[] {line_id, row_index,
line_code_text}` — строки `unreadable` / `missing` с суммами; кнопку «Подтвердить данные» отключать заранее с подсказкой
«Разберите строки без кода: N») → **`DUPLICATE_FORM`** (`detail.document_id` — в этом файле уже подтверждена форма того
же типа и периода) → `CHECKS_NOT_PASSED`.
line_code_text}` — строки `unreadable` / `missing`, у которых в ячейках есть сумма или нераспознанный текст; кнопку
«Подтвердить данные» отключать заранее с подсказкой «Разберите строки без кода: N») → **`DUPLICATE_FORM`**
(`detail.document_id` — в этом файле уже подтверждена форма того же типа, года и квартала) → `CHECKS_NOT_PASSED`.
**Статус документа.** Форма из OCR теперь бывает `RECOGNIZED` (и сразу автоподтверждается), если сервис всё подтвердил
проверками; `NEEDS_CHECK` — когда есть что смотреть: подсвеченные ячейки и строки, `issues` уровня `error`, неразобранные
строки. Две формы одного типа в одном файле приходят двумя документами; ни одна не автоподтверждается.
**Статус документа.** Форма из OCR может стать `RECOGNIZED` (и сразу автоподтвердиться), если сервис всё подтвердил
проверками; на текущих реальных сканах это редкость — `NEEDS_CHECK` остаётся, когда есть что смотреть: подсвеченные ячейки
и строки, `issues` уровня `error`, неразобранные строки. Две формы одного типа в одном файле приходят двумя документами;
ни одна не автоподтверждается.
**ГСК/ГСЗ.** `crm_id` в `POST …/related-groups` и `PATCH /directories/related-groups/{id}` сохраняется и возвращается в
`RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409 `CRM_ID_EXISTS` (arm-core#112, DBML v5.19).
@ -669,7 +680,7 @@ async function download(path: string, fallbackName: string): Promise<void> {
| `CHECKS_NOT_PASSED` | 409 | подтверждение формы | `failures[] {column_index, relation, expected, actual}` | показать нарушения, предложить акцепт |
| `LINES_UNRESOLVED` | 409 | подтверждение формы (после #116, §5.6.1) | `lines[] {line_id, row_index, line_code_text}` | перейти к первой строке без кода, предложить назначить код или «Это расшифровка» |
| `DUPLICATE_FORM` | 409 | подтверждение формы (после #116) | `document_id` | ссылка на уже подтверждённую форму того же типа и периода в файле |
| `LINE_EXISTS` | 409 | правка формы, назначение кода (после #116) | — | ошибка поля кода: код занят строкой, созданной правкой, с суммами |
| `LINE_CODE_TAKEN` | 409 | правка формы, назначение кода (после #116) | — | ошибка поля кода: код занят строкой, созданной правкой, с суммами |
| `INVALID_LINE_CODE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN` | 422 | правка формы `lines` / `columns` (после #116) | — | ошибка поля / ошибка клиента (перечитать форму) |
| `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement` не по форме ББ/ОФР | — | не открывать экран [41] для такого документа |
| `DOCUMENT_READONLY` | 409 | правка/акцепт подтверждённой формы | — | перевести экран в просмотр |

View File

@ -27,10 +27,25 @@
// (частичный индекс; ОСВ по счёту, DISCLOSURE, OTHER — «один на тип на файл», §9.5 п. 2); вторая форма того же типа
// в файле хранится (Р-10, Р-15); новая колонка file_ordinal — порядковый номер документа в результате, уникален в файле.
// 8. related_group.crm_id (varchar, nullable, unique) — ID ГСК/ГСЗ в ЕЦП CRM (AURORA-1619, arm-core#112, OQ-33).
// Заполнение при миграции: row_index строк — по возрастанию line_code внутри формы с 0, line_status = ok, needs_review
// строк = false, needs_review значений — по прежнему правилу values_ok (правлено или is_recognized не false и confidence
// 100 / NULL), source_text = string_value, is_confirmed = false, period_kind = absolute при period_date, иначе none,
// form_variant = NULL, file_ordinal — по (created_at, id) внутри файла с 0, related_group.crm_id = NULL.
// 9. Признак «строка создана правкой аналитика» — financial_statement_line.row_index IS NULL (заменяет прежний
// признак line_code_original_value IS NULL: у строк unreadable / missing / detail источника кода тоже нет).
// line_code_original_value — код строки из результата как пришёл (NULL у статусов кроме ok и у строк, созданных
// правкой); правками не меняется.
// 10. financial_statement_meta: header_source (jsonb — сырой текст и уверенность всех полей шапки результата,
// {"<поле>": {"text", "confidence"}}), organization_name_original_value и organization_name_modified (наименование
// правится PATCH шапки, как ИНН).
// 11. document_issue: codes (jsonb — коды цели и членов нарушения §7), column_index (столбец предупреждения вне колонок
// формы — COLUMN_SKIPPED и др.).
// Заполнение при миграции: row_index строк — NULL у строк с line_code_original_value IS NULL (созданных правкой), у
// остальных — по возрастанию line_code внутри формы с 0; line_status = ok, needs_review строк = false, needs_review
// значений — по прежнему правилу values_ok (правлено или is_recognized не false и confidence 100 / NULL), source_text =
// string_value, is_confirmed = false, period_kind = absolute при period_date, иначе none, form_variant = NULL (набор §7 —
// полный), header_source — из *_original_value и inn_confidence, organization_name_original_value = organization_name,
// file_ordinal — по (created_at, id) внутри файла с 0, related_group.crm_id = NULL; document_issue каждой формы —
// нарушения §7, посчитанные копией arm_statements по текущим суммам (source = core, kind relation / soft).
// Откат гарантирован на пустой БД и на данных, совместимых с v5.18 (нет повторов кода в форме, строк без кода, двух
// форм одного типа в файле, crm_id групп); на несовместимых данных downgrade останавливается с ошибкой и ничего не
// удаляет молча.
//
// v5.18 (решения владельца 09.10.2026; RSHB/arm-ka-docs#90, openspec/changes/console-files):
// 1. stored_file.consolidation_id (uuid, nullable, FK → consolidation): консолидация (ГК + год + квартал), в контексте
@ -963,8 +978,11 @@ Table financial_statement_meta {
checks_accepted_by uuid [ref: > app_user.id, note: 'v5.9 ТЗ 6.3 п.7: акцепт КА непройденных проверок (напр. 1370/2400) с комментарием']
checks_accepted_at timestamptz
checks_accepted_comment text
form_variant statement_form_variant [note: 'v5.19: full | simplified — по структуре кодов (§11.7); выбирает набор соотношений §7; NULL — документ до v5.19']
needs_review_fields jsonb [note: 'v5.19: имена полей шапки с needs_review (["report_date"]), как пришло от сервиса; правка поля убирает его из списка']
form_variant statement_form_variant [note: 'v5.19: full | simplified — по структуре кодов (§11.7); выбирает набор соотношений §7; NULL — документ до v5.19 или результат 1.x: набор полный']
needs_review_fields jsonb [note: 'v5.19: имена полей шапки, требующих проверки (["report_date"]), как пришло от сервиса; правка поля или подтверждение тем же значением убирает его из списка']
header_source jsonb [note: 'v5.19: шапка результата как пришла — {"<поле>": {"text": <поле>_text, "confidence": <поле>_confidence}} для organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address; не меняется. *_original_value — как прежде: inn_original_value = inn, report_date_original_value = report_date_text, units_original_value = units_text; inn_confidence = header_source.inn.confidence']
organization_name_original_value varchar [note: 'v5.19: наименование организации из результата; правками не меняется']
organization_name_modified boolean [note: 'v5.19: наименование правилось аналитиком (PATCH шапки)']
Note: 'Шапка распознанной формы ББ/ОФР. v5.19: результат проверок §7 и предупреждения хранятся в document_issue как пришли от сервиса и пересчитываются ядром только после правок (пересматривает «не хранятся — считаются на лету»)'
}
@ -996,14 +1014,14 @@ Table financial_statement_line {
id uuid [pk]
meta_id uuid [not null, ref: > financial_statement_meta.id]
line_code varchar [note: 'код строки ОКУД, напр. 1230']
line_code_original_value varchar
line_code_original_value varchar [note: 'v5.19: line_code строки из результата как пришёл (NULL у unreadable / missing / detail и у строк, созданных правкой); правками кода не меняется. До v5.19 NULL был признаком «создана правкой» — теперь это row_index IS NULL']
line_code_confidence numeric(5,2)
line_code_modified boolean
line_name varchar
line_name_original_value varchar
line_name_confidence numeric(5,2)
line_name_modified boolean [note: 'FIX: был numeric']
row_index int [note: 'v5.19: порядок строки в документе источника (с 0); NULL — строка шаблона, созданная правкой аналитика']
row_index int [note: 'v5.19: порядок строки в документе источника (с 0). NULL ⇔ строка создана правкой аналитика (шаблонная, origin = template); у строк из результата — всегда не NULL (маппер 1.x и 2.0)']
source_ref varchar [note: 'v5.19: положение в источнике (row:8, row:14#2, Баланс!C27); NULL у строк, созданных правкой']
line_status statement_line_status [not null, default: 'ok', note: 'v5.19: ok | unreadable | missing | detail; line_code пуст ⇔ статус не ok']
line_code_text varchar [note: 'v5.19: сырой текст ячейки кода, как распознано']
@ -1053,11 +1071,13 @@ Table document_issue {
id uuid [pk]
document_id uuid [not null, ref: > document.id]
kind document_issue_kind [not null]
code varchar [not null, note: 'код предупреждения (05-domain-rules.md §11.9) или код вида нарушения']
code varchar [not null, note: 'код предупреждения (05-domain-rules.md §11.9); у relation — RELATION_FAILED (severity error), у soft — RELATION_SOFT (severity info)']
severity issue_severity [not null]
message text [not null]
line_ids jsonb [note: 'массив financial_statement_line.id, к которым относится запись (нарушение — все строки кодов соотношения)']
line_ids jsonb [note: 'массив financial_statement_line.id, к которым относится запись (нарушение — все строки кодов соотношения); FK нет — при удалении шаблонной строки (назначение её кода строке источника) ядро убирает её id из line_ids всех записей документа']
column_id uuid [ref: > financial_statement_column.id]
column_index smallint [note: 'столбец сетки из предупреждения, если колонки нет среди financial_statement_column (COLUMN_SKIPPED и др.); у колонки формы — column_id']
codes jsonb [note: 'для relation / soft: коды цели и членов соотношения (["1600","1100","1200"]); экран подсвечивает строки этих кодов']
field varchar [note: 'поле шапки (report_date, inn, organization_name, …)']
source issue_source [not null]
relation varchar [note: 'для relation / soft: текст соотношения (1600 = 1100 + 1200)']

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 548 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@ -9,19 +9,22 @@ Change `openspec/changes/unified-statement-normalization`: формы ББ/ОФ
| № | Изменение | Решение |
|---|---|---|
| 1 | `financial_statement_line`: снята уникальность `(meta_id, line_code)` (индекс остаётся неуникальным); новые `row_index`, `source_ref` (частичный уникальный `(meta_id, source_ref) WHERE source_ref IS NOT NULL`), `line_status` (enum `statement_line_status`: `ok` / `unreadable` / `missing` / `detail`), `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed` | Повтор кода — законный случай (один код, разные наименования; Р-4): хранятся все строки, чтения «по коду» идут через агрегат формы по коду (сумма строк `ok`, не хранится — DECISION-5). Строки без кода хранятся со статусом (Р-8, отменяет OQ-36 / OQ-141): `detail` — расшифровка под строкой с кодом, в §7, агрегаты, КФО и ЕХ не входит; `unreadable` / `missing` блокируют подтверждение, пока у них есть суммы (Р-13). Порядок и положение в источнике (`row_index`, `source_ref`) нужны экрану, чтобы показать строку и предупреждение у места |
| 1 | `financial_statement_line`: снята уникальность `(meta_id, line_code)` (индекс остаётся неуникальным); новые `row_index`, `source_ref` (частичный уникальный `(meta_id, source_ref) WHERE source_ref IS NOT NULL`), `line_status` (enum `statement_line_status`: `ok` / `unreadable` / `missing` / `detail`), `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed` | Повтор кода — законный случай (один код, разные наименования; Р-4): хранятся все строки, чтения «по коду» идут через агрегат формы по коду (сумма строк `ok`, не хранится — DECISION-5). Строки без кода хранятся со статусом (Р-8, отменяет OQ-36 / OQ-141): `detail` — расшифровка под строкой с кодом, в §7, агрегаты, КФО и ЕХ не входит; `unreadable` / `missing` блокируют подтверждение, пока у них есть суммы (Р-13). Порядок и положение в источнике (`row_index`, `source_ref`) нужны экрану, чтобы показать строку и предупреждение у места. **Признак «строка создана правкой аналитика» — `row_index IS NULL`** (прежний признак `line_code_original_value IS NULL` не годится: у строк `unreadable` / `missing` / `detail` источника кода тоже нет); `line_code_original_value` — код из результата как пришёл, правками не меняется |
| 2 | `financial_statement_value`: `source_text`, `needs_review`, `is_confirmed`, `confirmed_by`, `confirmed_at` | `needs_review` **хранится** (пересматривает п. 4 v5.13): его считает сервис («подтверждение проверкой» §7 — Р-5, Р-7), ядро пересчитывает только после правок; подтверждение ячейки без правки аналитиком переживает пересчёт. Сырой текст источника — подсказка ячейки |
| 3 | `financial_statement_column`: `period_kind`, `label_confidence` | Относительные подписи бланка 66н («На отчётную дату», «На 31 декабря предыдущего года») — дата колонки от эффективной даты документа, ядро досчитывает после правки даты отчёта (Р-6); колонка без даты остаётся с `period_kind = none` |
| 4 | `financial_statement_meta`: `form_variant` (enum `statement_form_variant`), `needs_review_fields` | Упрощённая форма — свой набор соотношений §7; поля шапки, требующие проверки (сейчас — неуверенная дата отчёта) |
| 5 | Новая таблица `document_issue` + enum `document_issue_kind`, `issue_severity`, `issue_source` | Предупреждения и нарушения §7 документа с уровнем и привязкой к строкам (`line_ids`), колонке, полю шапки; как пришли от сервиса или посчитаны ядром. Очищается при перезагрузке файла; при пересчёте после правки `relation` / `soft` заменяются целиком, `warning` сервиса сохраняются, кроме снимаемых правкой |
| 4 | `financial_statement_meta`: `form_variant` (enum `statement_form_variant`), `needs_review_fields`, `header_source` (jsonb), `organization_name_original_value`, `organization_name_modified` | Упрощённая форма — свой набор соотношений §7 (`NULL` — документ до v5.19 или результат 1.x, набор полный); поля шапки, требующие проверки (сейчас — неуверенная дата отчёта); сырой текст и уверенность всех полей шапки результата (`<поле>_text`, `<поле>_confidence`) — подсказка у поля на экране; наименование организации правится, как ИНН |
| 5 | Новая таблица `document_issue` + enum `document_issue_kind`, `issue_severity`, `issue_source` | Предупреждения и нарушения §7 документа с уровнем и привязкой к строкам (`line_ids`), колонке (`column_id`, а вне колонок формы — `column_index`), полю шапки; у нарушения — `codes` (коды цели и членов); как пришли от сервиса или посчитаны ядром. Очищается при перезагрузке файла; при пересчёте после правки `relation` / `soft` заменяются целиком, `warning` сервиса сохраняются, кроме снимаемых правкой. **Единственный источник нарушений** для экрана и подтверждения: для результатов 1.x и форм, существовавших до миграции, ядро один раз считает §7 копией `arm_statements` и пишет записи с `source = core` |
| 6 | `recognition_job.statements_version` | Коммит копии общего кода `arm_statements`, которым посчитан результат (`service.statements_version`); расхождение с копией ядра при пересчёте — `info` |
| 7 | `document`: уникальность `(stored_file_id, document_type, osv_account_code)` — частичная, кроме `BALANCE` / `INCOME`; новая колонка `file_ordinal` + уникальный `(stored_file_id, file_ordinal)` | Вторая форма того же типа в файле хранится (Р-10, Р-15); при двух формах одного типа и периода ни одна не автоподтверждается, подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` (Р-18). ОСВ по счёту, расшифровка и прочий — по-прежнему один на тип на файл (§9.5 п. 2) |
| 8 | `related_group.crm_id` (`varchar`, nullable, unique) | ID ГСК/ГСЗ в ЕЦП CRM из форм `51-03`, `51-04`, `77-02` (AURORA-1619, arm-core#112, OQ-33); вводится вручную, импорт ЕЦП не заполняет (OQ-91); уникален, как `crm_id` ГК и ЮЛ |
Остальные таблицы и enum — как в v5.18 (44 таблицы, 33 enum). Реализация — одна миграция в `arm-core`; заполнение:
`row_index` — по возрастанию `line_code` в форме, `line_status = ok`, `needs_review` значений — по прежнему правилу
`values_ok`, `source_text = string_value`, `period_kind` — `absolute` при `period_date`, иначе `none`, `file_ordinal` — по
`(created_at, id)` в файле.
Остальные таблицы и enum — как в v5.18. Реализация — одна миграция в `arm-core`; заполнение: `row_index` — `NULL` у строк,
созданных правкой (`line_code_original_value IS NULL`), у остальных — по возрастанию `line_code` в форме; `line_status =
ok`, `needs_review` значений — по прежнему правилу `values_ok`, `source_text = string_value`, `period_kind` — `absolute`
при `period_date`, иначе `none`, `header_source` — из `*_original_value`, `file_ordinal` — по `(created_at, id)` в файле;
`document_issue` — нарушения §7 каждой формы, посчитанные копией `arm_statements` (`source = core`). Откат миграции
гарантирован на пустой БД и на данных, совместимых с v5.18 (без повторов кода, строк без кода и двух форм одного типа в
файле); на несовместимых данных `downgrade` останавливается с ошибкой.
## Что изменилось в v5.18 (решения владельца 09.10.2026, RSHB/arm-ka-docs#90)
@ -145,11 +148,11 @@ Change `openspec/changes/unified-statement-normalization`: формы ББ/ОФ
| Сущность | Что это | Ключевые связи | Откуда |
|---|---|---|---|
| `financial_statement_meta` | Шапка распознанной формы (1:1 с `document` BALANCE/INCOME): ИНН и наименование как в документе, отчётная дата, единица измерения, реквизиты. Ревью формы (`reviewed_by/at`) и акцепт КА непройденных логических проверок с комментарием. Подтверждение документа — `document.confirmed_by`. **v5.19:** `form_variant` (полная / упрощённая — набор соотношений §7), `needs_review_fields`; результат проверок §7 и предупреждения хранятся в `document_issue` как пришли от сервиса, ядро пересчитывает их только после правок. | → `document`, `recognition_job`, `app_user`; ← `column`, `line` | ТЗ 6.2/6.3; FNR01; макет |
| `financial_statement_meta` | Шапка распознанной формы (1:1 с `document` BALANCE/INCOME): ИНН и наименование как в документе, отчётная дата, единица измерения, реквизиты. Ревью формы (`reviewed_by/at`) и акцепт КА непройденных логических проверок с комментарием. Подтверждение документа — `document.confirmed_by`. **v5.19:** `form_variant` (полная / упрощённая — набор соотношений §7), `needs_review_fields`, `header_source` (сырой текст и уверенность полей шапки), правка наименования (`organization_name_original_value`, `organization_name_modified`); результат проверок §7 и предупреждения хранятся в `document_issue` как пришли от сервиса, ядро пересчитывает их только после правок. | → `document`, `recognition_job`, `app_user`; ← `column`, `line` | ТЗ 6.2/6.3; FNR01; макет |
| `financial_statement_column` | Колонки-периоды формы с датой — нужны для межформенной проверки (1370 на начало года) и гудвилла. **v5.19:** `period_kind` (абсолютная / относительная подпись бланка / без даты), `label_confidence`. | → `meta`; ← `value` | ТЗ 6.3 п.7, 6.7 |
| `financial_statement_line` | Строка формы как в источнике: код (или его сырой текст) и наименование как распознано. **v5.19:** все строки источника — с кодом (`ok`, повторы кода тоже) и без (`unreadable`, `missing`, `detail` — расшифровка под родителем `parent_row_index`); порядок `row_index`, положение `source_ref`, `needs_review` неуверенного кода, подтверждение кода `is_confirmed`. Раскладка экрана — по коду «как на бумаге». | → `meta`; ← `value`, `document_issue.line_ids` | ТЗ 6.2, Прил. 2 |
| `financial_statement_line` | Строка формы как в источнике: код (или его сырой текст) и наименование как распознано. **v5.19:** все строки источника — с кодом (`ok`, повторы кода тоже) и без (`unreadable`, `missing`, `detail` — расшифровка под родителем `parent_row_index`); порядок `row_index` (`NULL` ⇔ строка создана правкой аналитика), положение `source_ref`, `needs_review` неуверенного кода, подтверждение кода `is_confirmed`. Раскладка экрана — по коду «как на бумаге». | → `meta`; ← `value`, `document_issue.line_ids` | ТЗ 6.2, Прил. 2 |
| `financial_statement_value` | Число в ячейке (строка × колонка): сумма, уверенность, `is_recognized`, ручная правка с исходным значением. **v5.19:** подсветка — только `needs_review` (хранится: уверенность, `is_recognized` и §7 сведены сервисом, ядро пересчитывает после правок); `source_text` — сырой текст источника; `is_confirmed` / `confirmed_by/at` — подтверждено без правки. (v5.13 этот флаг убирала как производный.) | → `line`, `column`, `app_user` | ТЗ 6.2, Прил. 2 |
| `document_issue` | **v5.19, новая.** Предупреждение или нарушение §7 документа формы: вид (`warning` / `relation` / `soft`), код и уровень (`error` / `info`), сообщение, привязка к строкам (`line_ids`), колонке и полю шапки, источник (`service` / `core`); для нарушения — соотношение, `expected`, `actual`. Экран показывает их у места. | → `document`, `financial_statement_column` | change `unified-statement-normalization` (design, шаг 8) |
| `document_issue` | **v5.19, новая.** Предупреждение или нарушение §7 документа формы: вид (`warning` / `relation` / `soft`), код и уровень (`error` / `info`), сообщение, привязка к строкам (`line_ids`), колонке (`column_id` или `column_index`) и полю шапки, источник (`service` / `core`); для нарушения — соотношение, `codes`, `expected`, `actual`. Экран показывает их у места. | → `document`, `financial_statement_column` | change `unified-statement-normalization` (design, шаг 8) |
## Часть 07 — Консолидация

View File

@ -3,8 +3,10 @@
Issue: arm-ka-docs#116. Статус: **финальная версия** — целевая картина по шагам 1–9 согласована с владельцем
(решения Р-1…Р-19, 17.10.2026) и прошла три раунда независимого ревью (`review-2026-10-17.md`,
`review-2026-10-17-closure.md`, `review-2026-10-17-blind.md`, `review-2026-10-17-closure-2.md`,
`review-2026-10-17-blind-2.md`); следующий шаг — спека (дельты OpenSpec, контракт v2.0, §11/§12 в
`05-domain-rules.md`, DBML v5.19).
`review-2026-10-17-blind-2.md`). Спека (дельты OpenSpec, контракт v2.0, §7/§9/§11 в `05-domain-rules.md`, DBML v5.19)
написана 18.10.2026 (решения S-1…S-25), прошла ревью вслепую (`review-2026-10-18-spec.md`, находки K-1…K-25) и
доработана по нему (решения владельца Р-20…Р-22, решения S-26…S-52 ниже). Где текст дизайна выше расходится с
решениями Р-20…Р-22 и S-n, действуют они и спека.
## Цель
@ -773,7 +775,7 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо
`07-open-questions.md` (Р-15). Вопрос о сопоставлении файлового наименования со справочным закрыт Р-16: для
неуверенных кодов это условие `RECOGNIZED`, для остальных — только показ справочного имени.
### Решения владельца (Р-1…Р-19, 17.10.2026)
### Решения владельца (Р-1…Р-19 — 17.10.2026, Р-20…Р-22 — 18.10.2026)
- **Р-1** (шаг 1, п. 2) — **копия по скрипту.** Платформа не собирает Python-пакеты и не кладёт их в свой Nexus, поэтому
общий код — dataclass-ы шага 1 (сериализуемые в json), шаги 2–8, словари §7, периодов, заголовков форм, перечни и
@ -855,6 +857,24 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо
предупреждений `error` уровня файла (потерянный json или таблица ИС блокируют, `needs_check` соседнего документа
из-за одной ячейки — нет).
- **Р-20** (18.10.2026; ревью спеки K-4, повторы кодов) — законные повторы кодов (в реальных ББ 2025 — 1220 «НДС,
начисленный по отгрузке» и 1520 «Обязательство по аренде») допускаются в упорядоченном перечне кодов: структура
подтверждает обе копии. Правило Р-16 «повтор с неуверенной копией — `needs_review`» применяется только к повтору,
которого нет в перечне. Словарь имён ядра — список имён (синонимов) на код; загрузчик перестаёт запрещать повтор кода в
этом смысле (сверка наименования — с любым из имён). В спеке: перечень `contracts/statement-code-orders.yaml`
(`repeats`: у `fsbu4` — 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО, см. S-28), §11.9, §7 «Полный
состав формы», спеки `statement-normalization` и `arm-core`.
- **Р-21** (18.10.2026; ревью спеки K-4, мягкие соотношения ОФР) — неуверенное значение подтверждается и мягким
соотношением, если оно сошлось точно (расхождение 0 или в пределах допуска ±1; не «в пределах округления»). Применяется
к 2400, 2410, 2411, 2412, 2460 и прочим строкам, у которых есть только мягкие соотношения. Знак для `abs_terms` — по
прежнему правилу конвенции колонки. В спеке: §7, §11.9, требование «Шаг 8 — `needs_review` значения».
- **Р-22** (18.10.2026; решения писателя S-1…S-25) — приняты все, с оговорками ревьюера из таблицы S-n в
`review-2026-10-18-spec.md`: S-4 — синонимы на код и исключение «итого по разделу» из правила вхождения; S-5 — в
упрощённом ОФР `2400` мягкое, как в полном, ББ — жёсткие; S-7 — OQ-148 закрывается этим решением; S-10 / S-22 — одна
формулировка; S-13 — двойники внутри детектора нумерации и допуск одного пропуска; S-15 — экранирование имени листа в
Excel-`ref`; S-17 — доопределить по K-23; S-19 — `codes` и `column_index` у `document_issue`; S-21 — по K-2 / K-21.
Явно утверждены владельцем S-4, S-5, S-7, S-10, S-12, S-13, S-14, S-20, S-22, S-25 (отмечены ниже).
## Decisions made without discussion
Решения агента 17.10.2026, принятые при оформлении целевой картины без отдельного обсуждения с владельцем; все
@ -936,14 +956,16 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо
27. **S-4. Совпадение наименований (Р-16):** нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел; совпало при
равенстве, вхождении более короткой строки (≥ 5 символов) или `difflib.SequenceMatcher` ≥ 0,8 с любым справочным
именем кода в любой редакции; нет кода в словаре имён — не совпало. Цена: ложное несовпадение — `NEEDS_CHECK` и
ручное подтверждение; ложное совпадение маловероятно у разных статей одного раздела.
ручное подтверждение; ложное совпадение маловероятно у разных статей одного раздела. **Утверждено владельцем 18.10.2026 (Р-22)** с оговоркой: у кода несколько имён (синонимы, Р-20); если
одно из имён начинается с «итого по разделу», совпадение — только равенство (S-49).
28. **S-5. Формулы упрощённого набора** по бланку приказа 66н, прил. 5 (ББ: `1600 = 1150 + 1170 + 1210 + 1230 + 1250`,
`1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550`, `1600 = 1700`; ОФР: `2400 = 2110 − 2120 − 2330 + 2340
− 2350 − 2410`), все жёсткие; `ASSUMPTION` до образца (OQ-166). Цена: неверная формула — `needs_check` упрощённых форм.
− 2350 − 2410`), все жёсткие; `ASSUMPTION` до образца (OQ-166). Цена: неверная формула — `needs_check` упрощённых форм. **Утверждено владельцем 18.10.2026 (Р-22) с изменением:** ОФР `2400 = …` упрощённого набора —
мягкое, как `2400` полного (знак 2410, OQ-12; ревью K-11); соотношения ББ — жёсткие.
29. **S-6. Словари подписей:** кода — «код», «коды», «код строки», «код показателя»; наименования — подпись,
начинающаяся с «наименование». Цена: пополняется словарём.
30. **S-7. Прочерк** (`-`, `–`, `—`, `−` без цифр) — пустое значение, `is_recognized = true` (как пустая ячейка, Р-2);
OQ-148 — допущение. Цена: тире-мусор на месте цифры не подсвечивается сам по себе, его ловит §7 (пустой член).
OQ-148 — допущение. Цена: тире-мусор на месте цифры не подсвечивается сам по себе, его ловит §7 (пустой член). **Утверждено владельцем 18.10.2026 (Р-22):** OQ-148 закрыт этим решением.
31. **S-8. Каталог кодов предупреждений** (таблица §11.9 `05-domain-rules.md`); `code` в контракте — строка
`^[A-Z][A-Z0-9_]*$`, не перечисление (новый код — не MAJOR); `VALUE_OUT_OF_RANGE` — `info` (значение и так
`needs_review`). Цена: опечатка кода не ловится схемой — ловится тестами пакета.
@ -951,42 +973,163 @@ line_name, line_name_confidence, parent_ref, values}`: `line_code` — разо
33. **S-10. `needs_review` полей шапки** — массив `header.needs_review_fields` в контракте и `jsonb` в
`financial_statement_meta`; сейчас туда попадает только `report_date`: сомнения в ИНН и организации — `info` по
дизайну, `needs_review` у них дал бы `needs_check` вопреки «НПЗ ОФР без ИНН остаётся `done`». Цена: экран подсвечивает
ИНН и организацию по `issues` с `field`, а не по `needs_review`.
ИНН и организацию по `issues` с `field`, а не по `needs_review`. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка одна во всех документах:
`needs_review_fields` — поля, которые держат документ в `needs_check`; сырой текст и уверенность всех полей шапки
хранятся (`header_source`, S-33).
34. **S-11. Значения `period_kind`:** `absolute`, `relative:current`, `relative:prev_year_end`,
`relative:prev_prev_year_end`, `relative:prev_period`, `none`; «За аналогичный период» — эффективная дата − 1 год
(29.02 → 28.02). Цена: новая относительная подпись — новое значение (MINOR).
35. **S-12. Ожидаемые даты неуверенной подписи колонки** — эффективная дата, она же − 1 и − 2 года **и 31 декабря
предыдущего и предшествующего ему года**. Основание: у квартального ББ колонки «на 31.12 прошлого года», и набор
дизайна (дата, − 1 год, − 2 года) давал бы `error` на каждой такой форме. Цена: чуть шире окно подтверждения.
дизайна (дата, − 1 год, − 2 года) давал бы `error` на каждой такой форме. Цена: чуть шире окно подтверждения. **Утверждено владельцем 18.10.2026 (Р-22).**
36. **S-13. Строка нумерации** — не меньше трёх непустых ячеек, все целые, подряд с 1 слева направо. Цена: нумерация с
пропуском не распознаётся (строка уйдёт в данные со статусом).
пропуском не распознаётся (строка уйдёт в данные со статусом). **Утверждено владельцем 18.10.2026 (Р-22) с оговоркой:** двойники внутри детектора и допуск
одного пропуска — уточнено S-40.
37. **S-14. Признак повторной шапки** — подпись кода в строке или ≥ 2 непустых ячеек, совпавших с шапкой первого
сегмента. Цена: шапка без «Код» и с другими подписями не начнёт сегмент.
сегмента. Цена: шапка без «Код» и с другими подписями не начнёт сегмент. **Утверждено владельцем 18.10.2026 (Р-22).**
38. **S-15. `ref` и `row_index`:** `row_index` с 0; Excel `ref` строки — адрес ячейки столбца кодов (без столбца —
первой непустой ячейки); разлепленные `row:<i>#<k>` с k от 1; у разлепленных строк `line_code_text` и `text` —
k-е части. Цена: нулевая.
k-е части. Цена: нулевая. Оговорка Р-22: имя листа в Excel-`ref` экранируется по правилу Excel (кавычки, удвоенный апостроф),
примеры контракта исправлены.
39. **S-16. Уверенность кода с заменой двойника** — не выше 99 (так «неуверенный» виден из одного поля). Цена: нулевая.
40. **S-17. Формат PATCH `lines`** — один элемент = одно действие (`line_code` | `line_status: "detail"` | `confirmed`);
коды 422 `INVALID_LINE_CODE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN`; правка суммы
по `line_code` при наличии строки `ok` — 422 `UNKNOWN_CELL`. Цена: имена кодов меняются до реализации без последствий.
по `line_code` при наличии строки `ok` — 422 `UNKNOWN_CELL`. Цена: имена кодов меняются до реализации без последствий. Оговорка Р-22: доопределено по K-23 (S-47); 409 `LINE_EXISTS` переименован в `LINE_CODE_TAKEN`
(S-43).
41. **S-18. Детали 409:** `LINES_UNRESOLVED` — `detail.lines[] {line_id, row_index, line_code_text}`; `DUPLICATE_FORM` —
`detail.document_id`. Цена: нулевая.
42. **S-19. Имена DBML v5.19:** `document.file_ordinal` (с 0, уникален в файле), `financial_statement_meta.needs_review_fields`
(`jsonb`), `document_issue.line_ids` (`jsonb` — сверка ORM↔DBML не знает массивов), enum'ы `statement_line_status`,
`statement_form_variant`, `document_issue_kind`, `issue_severity`, `issue_source`; индекс `(meta_id, line_code)` остаётся
неуникальным; у строки `is_confirmed` без `confirmed_by/at` (строго по дизайну, кто — в аудите). Новые колонки — последними
в таблицах (как их добавит миграция). Цена: нулевая до миграции.
в таблицах (как их добавит миграция). Цена: нулевая до миграции. Оговорка Р-22: у `document_issue` добавлены `codes` и `column_index` (S-31, S-35); висячий
`line_id` удалённой шаблонной строки убирается из `line_ids`.
43. **S-20. `related_group.crm_id` уникален** (как у ГК и ЮЛ), повтор — 409 `CRM_ID_EXISTS`. Цена: две группы с одним ID
CRM завести нельзя; так и должно быть.
CRM завести нельзя; так и должно быть. **Утверждено владельцем 18.10.2026 (Р-22).**
44. **S-21. Значения по умолчанию при приёме 1.x:** `ref = null`, `needs_review` строки `false`, `period_kind` — `absolute`
при дате иначе `none`, `form_variant = null`, строки-предупреждения — `LEGACY_WARNING` `info`. Цена: на окне выкладки
документы 1.x без новых сигналов качества.
документы 1.x без новых сигналов качества. Оговорка Р-22: по K-2 / K-21 — S-36.
45. **S-22. Статус документа ОСВ** — `done`, когда контрольные суммы §1.4 листа сошлись, иначе `needs_check` (прежнее
правило конверта, применённое к листу). Цена: нулевая — конверт ведёт себя как сейчас.
правило конверта, применённое к листу). Цена: нулевая — конверт ведёт себя как сейчас. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка «контрольные суммы §1.4 листа сошлись»
теперь одна в контракте, §11, спеках `recognition-contract` и `tabular-recognition-service`.
46. **S-23. `service.statements_version` обязателен**, если в результате есть `statementDocument` (схема проверяет). Цена:
результат только с ОСВ может его не нести.
47. **S-24. Редакция перечня кодов в сервисах** — `fsbu4` при эффективной дате после 2024-12-31 (то же правило, что
`edition` ядра, OQ-145). Цена: досрочный ФСБУ 4/2023 — проверяются обе через «длиннейшую подпоследовательность».
`edition` ядра, OQ-145). Цена: досрочный ФСБУ 4/2023 — проверяются обе через «длиннейшую подпоследовательность». Уточнено S-26: проверяются всегда обе, дата решает только ничью.
48. **S-25. Квартал периода (условие `RECOGNIZED`):** дата не конец квартала → `NEEDS_CHECK` (как D-6), ручное
подтверждение — 409 `PERIOD_REQUIRED`. Основание: оговорка дизайна «иначе документ `RECOGNIZED`, но не подтверждаемый»
противоречит автоподтверждению `RECOGNIZED`. Цена: нулевая — поведение как сейчас.
противоречит автоподтверждению `RECOGNIZED`. Цена: нулевая — поведение как сейчас. **Утверждено владельцем 18.10.2026 (Р-22).**
### Решения при доработке спеки по ревью K-1…K-25 (18.10.2026)
Решения агента при закрытии находок `review-2026-10-18-spec.md` там, где ни ревьюер, ни владелец не задали значение,
или где агент отступил от предложения ревьюера. Маркер `S-n` продолжает нумерацию; все обратимы правкой спеки, словаря
или DBML до реализации.
49. **S-26. Редакции перечня кодов — всегда обе** (K-5): для неуверенного кода считаются наибольшие
подпоследовательности по `66n` и `fsbu4`, берётся длиннейшая, эффективная дата решает только ничью. Основание:
Межениновская за 3 кв. 2025 сдана на бланке 66н — выбор только по дате (S-24) не подтвердил бы её 1120; формула
дизайна «редакция — где длиннее, при равенстве — по дате» иначе не имеет смысла. Цена: на ББ с кодами, общими для
обеих редакций, выбор безразличен; неверный выбор возможен только при равной длине — тогда решает дата, как в S-24.
50. **S-27. Перечень кодов — данные в docs** (K-5): источник — `contracts/statement-code-orders.yaml` (обращение «как
контракт»: общие данные сервисов и ядра), в пакете — байтовая копия `rules/statement_code_orders.yaml`, идентичность
проверяет `sync-statements --check`; форма файла — `scripts/validate_contracts.py`, для этого в `make venv` docs
добавлен PyYAML. Цена: ещё одна зависимость docs-проверок (`make venv` нужно перезапустить); перенос источника в
`arm-statements` — правкой одного требования.
51. **S-28. Состав перечня — только подтверждённые образцом коды** (K-5): шаблоны банка + коды печатных форм 1С и сканов
из `fixtures/` (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; раздел III НКО
(1310 «Паевой фонд» … 1370, 1300 — aep_3) — законный повтор `66n`; клиентский 1231 — в `66n` (Унагранде 2024).
Коды без образца (1120 у `fsbu4`, 2421 / 2430 / 2450 у `66n`, упрощённые перечни) — OQ-168, OQ-166. Все 12
последовательностей кодов из `fixtures` (Excel и OCR) укладываются в свою редакцию целиком. Цена: на скане с
неподтверждённым кодом и неуверенностью ниже 100 — `needs_review` строки; у Excel и уверенного кода — только `info`.
52. **S-29. Окно перехода контракта** (K-8): схемы 1.2 / 1.0 лежат в docs как `*.v1.schema.json` (байтовые копии,
сервисы сверяются с ними без правки копий), примеры — `examples/v1/`; тест идентичности сервиса выбирает файл по
`$id` копии (`…/v1` → `*.v1.schema.json`) — это шаг 0 выкладки в tabular-parser и scan-gateway до слияния docs.
Отступление от предложения ревьюера «тест сверяет с MAJOR, который сервис пишет»: признак — `$id` копии, а не
настройка сервиса, чтобы правило работало без знания кода сервиса. Цена: маленький PR в двух сервисах до слияния
docs; без него их `make check` красный на окне.
53. **S-30. Механика `sync-statements`** (K-9): копия — `api/arm_statements/` (импорт как `src`), тесты пакета в
`testpaths` хоста, `mypy src arm_statements`; маркер — файл `api/arm_statements/VERSION` с полным хэшем; `--check`:
`0` синхронно, `10` отстала (зелёный с предупреждением — иначе любой посторонний PR в сервис требовал бы
подтягивать чужие изменения пакета), `20` прямая правка / чужая версия / расхождение перечня кодов / код словаря ядра
вне перечня, `2` нет checkout или аргумент. Без checkout `arm-statements` `check-full` красный (код 2): это проверка
перед PR, а не `make check`. Цена: отставшая копия проходит `check-full`; видна в описании PR.
54. **S-31. `column_index` — номер столбца сетки кандидата** (K-6): у OCR — `column_index` ИС как есть (разреженный), у
Excel — номер столбца листа первого сегмента с 0; у `document_issue` — `column_id` для колонки формы и
`column_index` для столбца вне колонок; `line_refs` — только строки `lines[]`. Отступление от варианта «порядковый
номер колонки значений»: иначе пропущенная колонка (`COLUMN_SKIPPED`) не имела бы номера. Примеры контракта
приведены к правилу (Excel — 3, 4; OCR — 2). Цена: номера не с 0 и не подряд — экран и ядро не должны считать их
позициями.
55. **S-32. `TABLE_PARSE_ERROR` и `SHEET_OTHER` — предупреждения файла** (K-7): с `locator`; документа нет;
`TABLE_PARSE_ERROR` (`error`) блокирует `RECOGNIZED` всех документов файла, как `JSON_FAILED`. Цена: один упавший
разбор таблицы держит весь файл на ручном подтверждении.
56. **S-33. Шапка: `header_source jsonb` + прежние `*_original_value`** (K-3): сырой текст и уверенность всех десяти
полей — одной колонкой `{поле: {text, confidence}}`; `inn_original_value`, `report_date_original_value`,
`units_original_value` заполняются как прежде, `inn_confidence` остаётся. Отступление от варианта «колонки на каждое
поле»: 20 колонок ради подсказки на экране. Цена: поиск по сырому тексту поля шапки в SQL — через `jsonb`.
57. **S-34. `organization_name` в PATCH шапки** (K-12): непустая строка до 1000 символов, `organization_name_modified`,
`organization_name_original_value` (новые колонки DBML), пересчёт `counterparty_check`, аудит; снимает
`NO_ORGANIZATION_NO_INN`. Выбран вариант «добавить», а не «убрать из правила снятия»: иначе форма без шапки
снималась бы только перезагрузкой. Цена: правка наименования влияет на сверку контрагента по алиасам.
58. **S-35. Коды нарушений в `document_issue`** (K-13): `relation` → `RELATION_FAILED` / `error`, `soft` →
`RELATION_SOFT` / `info`, колонка `codes jsonb`, `line_ids` — все строки `ok` кодов. Цена: нулевая.
59. **S-36. Формы без `checks`** (K-2, K-21): у результата 1.x и у форм до миграции ядро один раз считает §7 копией
пакета (`form_variant = null` — полный набор) и пишет `document_issue` (`source = core`): при приёме 1.x и **в
ревизии миграции** (ревизия импортирует копию `arm_statements`). D-6 «проверки сошлись» для 1.x — из этого расчёта;
`GET` и `confirm` читают нарушения только из `document_issue`. Предупреждения маппера 1.x по документу —
`MAPPING_WARNING` `info`; строки `metrics.warnings` 1.x — `LEGACY_WARNING` в `recognition_job.metrics` (уровень
файла, не `document_issue`); `STATEMENTS_VERSION_MISMATCH` при `statements_version = null` не пишется. Цена: ревизия
миграции зависит от кода пакета на момент миграции (старая ревизия при смене пакета считает по новым правилам — для
разовой миграции приемлемо); отказ от миграционного расчёта дал бы потерю нарушений у существующих форм (K-2).
60. **S-37. Признак строки правки — `row_index IS NULL`** (K-1): маппер 1.x и 2.0 всегда пишет `row_index`; миграция
ставит `NULL` строкам с `line_code_original_value IS NULL`; `line_code_original_value` — код из источника, правками
не меняется. Отступление от дизайна («смена кода — прежний в `line_code_original_value`»): при первой смене это то
же значение, а при повторной правке исходный код источника не теряется. Цена: «предыдущий код» после двух правок —
только в аудите.
61. **S-38. Функция статуса и «содержимое» строки** (K-14): вход — данные без типов источника (`layout_found`, у ядра —
есть строка из источника; `has_organization_or_inn`; `period_known`; статусы и `needs_review`; число жёстких
нарушений; `accepted`; уровни предупреждений). Содержимое строки — цифра в ячейке кода, сумма в значениях или
непустая ячейка значений с уверенностью ниже 100; строка `unreadable` без значений («44 30\nI |» aep_6) — строка
данных: документ `needs_check`, подтверждение не блокирует. Цена: такая строка требует от аналитика «Это
расшифровка» или кода, хотя сумм в ней нет.
62. **S-39. Столбец кодов ищется всегда, и у `DISCLOSURE`** (K-10): `type_hint = DISCLOSURE` при кодах ББ/ОФР → тип по
кодам + `TYPE_CONFLICT`; у `DISCLOSURE` шаги 3–7 не выполняются, `columns` / `lines` — пустые массивы. Цена: лишний
проход поиска столбца по пояснениям.
63. **S-40. Детектор нумерации** (S-13 с оговоркой Р-22, K-15): ≥ 3 непустых ячеек, каждая после замены двойников —
целое; возрастают на 1, начинаются с 1 или 2, допускается один пропуск. Цена: настоящая строка данных из трёх
маленьких целых подряд («1 | 2 | 3» в суммах) станет нумерацией — на формах ББ/ОФР не встречалась.
64. **S-41. `LINES_UNRESOLVED` — сумма или текст** (K-16): блокирует строка `unreadable` / `missing`, у которой хотя бы
в одной ячейке `amount` или `string_value` не `null`. Цена: строка с мусором вместо суммы требует разбора.
65. **S-42. Подтверждение неуверенной даты отчёта** (K-17): PATCH `header.report_date` тем же значением у поля из
`needs_review_fields` снимает его и `REPORT_DATE_UNCERTAIN`; у бланка 66н только с относительными подписями это
единственный путь — признано в §11.9. Цена: аналитик должен явно подтвердить дату на каждом таком бланке.
66. **S-43. Имена** (K-18): 409 `LINE_EXISTS` → `LINE_CODE_TAKEN` (имя занято сообщением 422 `UNKNOWN_CELL` в коде
ядра); `period_end` колонки → `period_date` во всех документах (как в контракте и DBML); порядок `lines` в API —
«по возрастанию кода, как сейчас», «как на бумаге» — раскладка экрана; `DISCLOSURE` — пустые массивы. Цена: нулевая.
67. **S-44. Откат миграции** (K-19): гарантирован на пустой и совместимой с v5.18 БД; на несовместимых данных
`downgrade` останавливается с ошибкой и ничего не удаляет. Цена: откат после появления данных v2.0 — только
восстановлением из резервной копии.
68. **S-45. «Того же периода»** (K-20) — та же пара `period_year`, `period_quarter` (как автозамена §9.5 п. 3); формы
без квартала не считаются (их и так держит `PERIOD_REQUIRED`). Цена: нулевая.
69. **S-46. Единицы и ОКЕИ** (K-22): словарь `statement_units.yaml` в пакете (написания и коды ОКЕИ 383 / 384 / 385);
единицы по тексту, иначе по ОКЕИ; `okei` (+ `_text`, `_confidence`) — в `header` контракта v2.0 и `headerField`.
Цена: ОКЕИ, противоречащий тексту единиц, не ловится (текст главнее).
70. **S-47. Пробелы PATCH** (K-23, S-17): `confirmed: false` снимает подтверждение ячейки и кода; `line_status` принимает
только `"detail"` (`"ok"` — 422 `LINE_CODE_REQUIRED`); обратного `detail` → `unreadable` / `missing` нет (исправляется
назначением кода, исходное — перезагрузкой); `columns[].period_date = null` — 422 валидации; **акцепт сбрасывает
правка данных** (сумма, код, статус строки, дата колонки, поле шапки), подтверждения (`confirmed`) — нет. Последнее
сужает правило Среза 3b «акцепт сбрасывается любой правкой формы» — вынесено владельцу (отчёт O-n). Цена: если
владелец считает подтверждение правкой — одна строка спеки.
71. **S-48. Страховка `done`** (K-24): `done` из результата при строке `unreadable` / `missing` со значением,
`needs_review`, `relations_failed` или `error`-предупреждении документа → `NEEDS_CHECK` и `RESULT_STATUS_INCONSISTENT`
`info`. Цена: проверка на каждом маппинге — линейная по строкам.
72. **S-49. Словарь имён ядра** (Р-20, S-4): повтор кода в редакции разрешён — все имена кода синонимы; шаблонная строка
и `reference_name` берут первое; «итого по разделу» — только равенство. Цена: справочное имя законного повтора на
экране — первое имя кода, файловое — рядом.
73. **S-50. Толкование Р-21:** мягкое соотношение, сошедшееся в пределах ±1, покрывает значения любого своего кода; у
кода в несошедшемся жёстком соотношении подсветка остаётся (правило «все ячейки несошедшегося жёсткого»), поэтому
на практике Р-21 действует на строки без жёстких соотношений, как сформулировал владелец. Цена: нулевая.
74. **S-51. Ожидания на реальных сканах признаны в §11.9 и proposal** (K-4): большинство из шести OCR-документов
останутся `needs_check`; формулировка гайда фронта смягчена («может стать `RECOGNIZED`»). Цена: нулевая.
75. **S-52. Манифест tabular-parser** (K-25): в спеке `tabular-recognition-service` перечислены записи, получающие
`1600 = 1700` (страницы 2 баланса ×3), и записи, чьи статусы `done` меняться не должны. Цена: нулевая.

View File

@ -30,7 +30,10 @@
предупреждения — структуры `{code, severity, message, line_refs, column_index, field}` вместо строк.
- **Контракт `recognition-result.schema.json` v2.0** (MAJOR, Р-9) и конверт `recognition-result-message.schema.json`
v2.0 (структурные `metrics.warnings`); запрос `recognition-request` остаётся v1.0. Путь ОСВ в tabular-parser
меняется только в части структуры предупреждений и `document.status`.
меняется только в части структуры предупреждений и `document.status`. На окне перехода схемы 1.x лежат рядом
(`*.v1.schema.json`, примеры `examples/v1/`), тест идентичности сервиса сверяет копию со схемой своего MAJOR.
- **Упорядоченные перечни кодов форм** — данные `contracts/statement-code-orders.yaml` (66н / ФСБУ 4, полная /
упрощённая, законные повторы кода — Р-20); в пакет `arm_statements` — байтовой копией.
- **arm-core:** DBML v5.19 и одна миграция (статусы и порядок строк, `needs_review`, `is_confirmed`, `source_text`,
`period_kind`, `form_variant`, таблица `document_issue`, снятие уникальности `(meta_id, line_code)` и — для ББ/ОФР —
`document (stored_file_id, document_type, osv_account_code)`, `recognition_job.statements_version`,
@ -53,6 +56,8 @@
### Порядок выкладки — «сначала ядро» (обязателен)
0. tabular-parser и scan-gateway: тест идентичности копии контракта выбирает файл docs по MAJOR копии (`$id` `…/v1` →
`*.v1.schema.json`) — до слияния шага 2, иначе их `make check` краснеет на окне перехода.
1. `arm-statements` — пакет с тестами; `sync-statements` в docs.
2. docs: контракт v2.0, DBML v5.19, правила (этот change).
3. arm-core: миграция v5.19, маппер 2.0 рядом с маппером 1.x, приём ack и конвертов 1.x и 2.0, PATCH, экранные поля
@ -62,7 +67,12 @@
6. После перехода обоих сервисов маппер 1.x удаляется из ядра отдельным PR (не в этом change).
Копии схем в сервисах (`api/contracts/`, тест идентичности в `make check`) после слияния контракта v2.0 в docs
устаревают: каждый сервис обновляет копию в своём PR шага 5, ядро — в PR шага 3.
сверяются с `*.v1.schema.json` (шаг 0); каждый сервис обновляет копию до v2.0 в своём PR шага 5, ядро держит копии обоих
MAJOR с PR шага 3. Схемы `*.v1.schema.json` и `examples/v1/` удаляются вместе с маппером 1.x (шаг 6).
**Чего ждать на реальных сканах.** На шести OCR-документах `fixtures/ocr` после перехода большинство форм останутся
`needs_check` (строки без кода с суммами, неуверенные значения вне соотношений, неуверенная дата); законные повторы кода
и мягкие строки ОФР перестают держать документ (Р-20, Р-21). Доля `done` фиксируется в PR scan-gateway.
## Non-goals
@ -93,21 +103,30 @@
### Modified Capabilities
- `recognition-contract`: файл результата и конверт — v2.0; правила перехода (ядро принимает 1.x и 2.0, «сначала
ядро»); статус и структурные предупреждения у всех документов, включая ОСВ.
ядро»); статус и структурные предупреждения у всех документов, включая ОСВ; тест идентичности копии — по MAJOR.
- `quality-checks`: `make check` охватывает копию `arm_statements` (тесты, `mypy`), `make check-full` — `statements-check`
(`sync-statements --check`).
## Impact
- Новый репозиторий `arm-statements`; скрипт `sync-statements` и цель Makefile в docs (вызывается из `make check-full`
потребителей, не из `make check`).
потребителей, не из `make check`; коды 0 / 10 / 20 / 2).
- docs: `contracts/statement-code-orders.yaml`, схемы `*.v1.schema.json` и `examples/v1/` на окне перехода;
`scripts/validate_contracts.py` проверяет оба MAJOR и перечни кодов, `make venv` ставит PyYAML.
- tabular-parser: `parser/statements/*` (кроме разбора книги и адаптера шага 1) уходит в общий код; словари
`statement_checks.yaml`, `statement_periods.yaml`, `statement_headers.yaml` переезжают в пакет; путь ОСВ — структура
предупреждений и `document.status`.
предупреждений и `document.status`; манифест `api/tests/statements_manifest.yaml` обновляется: `failed_relations`
страниц 2 баланса (`unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`,
`unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls`) получают `1600 = 1700` («посчитанное» соотношение, пустая цель),
статусы `done` не меняются.
- scan-gateway: `normalizer/ocr/text|dates|amounts` становятся общими разборами, `layout|rows|header|document` — шагами
2–6 общего кода; `api/rules/statement_lines.yaml` удаляется, `statement_labels.yaml` и `ocr_lookalikes.yaml` уходят в
словари пакета.
- arm-core: миграция v5.19 (`financial_statement_*`, `document`, `document_issue`, `recognition_job`,
`related_group`), `result_mapping`, `statement_mapping`, `statements`, `documents`, `auto_confirm`, схемы API формы;
`api/rules/statement_checks.yaml` удаляется (копия словаря §7), `statement_lines.yaml` остаётся только словарём имён.
`related_group`; `document_issue` существующих форм — §7 копией пакета), `result_mapping`, `statement_mapping`,
`statements`, `documents`, `auto_confirm`, схемы API формы; признак строки правки — `row_index IS NULL`;
`api/rules/statement_checks.yaml` удаляется (копия словаря §7), `statement_lines.yaml` остаётся только словарём имён
(несколько имён на код, загрузчик повтор кода не запрещает); PATCH шапки принимает `organization_name`.
- arm-web: `widgets/DocumentReview` (экран [41]), типы API.
- Решение v5.13 «`needs_review` считается, не хранится» пересматривается (design, Decisions № 17).
- Риск окна выкладки: пока сервис шлёт 1.x, документы его конвейера получают статус по прежнему правилу D-6 и поля

View File

@ -3,16 +3,17 @@
### Requirement: Хранение результата формы v2.0 (DBML v5.19)
Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений:
каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`,
`line_code_text`, `line_code_confidence`, `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки
с `ref = parent_ref`, `needs_review`, `is_confirmed = false`); каждая пара «строка × колонка» — одна
`financial_statement_value` (`amount`, `string_value`, `source_text` = `values[].text`, `confidence`, `is_recognized`,
`needs_review`; значения, которого нет в результате, — пустая запись с `needs_review = false`); колонки —
`financial_statement_column` с `period_kind`, `label_confidence`; шапка — `financial_statement_meta` с `form_variant`
и `needs_review_fields`; предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего
кода — `recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)`
уникален там, где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным
`document` с `file_ordinal` (порядковый номер документа в результате); вторая форма того же типа в файле хранится
(Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл.
`line_code_original_value` = `line_code` результата, `line_code_text`, `line_code_confidence`, `line_name`,
`line_name_confidence`, `parent_row_index` — `row_index` строки с `ref = parent_ref`, `needs_review`, `is_confirmed =
false`); каждая пара «строка × колонка» — одна `financial_statement_value` (`amount`, `string_value`, `source_text` =
`values[].text`, `confidence`, `is_recognized`, `needs_review`; значения, которого нет в результате, — пустая запись с
`needs_review = false`); колонки — `financial_statement_column` с `period_kind`, `label_confidence`; шапка —
`financial_statement_meta` с `form_variant`, `needs_review_fields` и `header_source` (раздел «Шапка формы»);
предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода —
`recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` уникален там,
где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document` с
`file_ordinal` (порядковый номер документа в результате); вторая форма того же типа в файле хранится (Р-10); ОСВ по
счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл.
#### Scenario: Повтор кода сохраняется
- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`)
@ -20,12 +21,43 @@
#### Scenario: Строка без кода
- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой
- **THEN** строка хранится с `line_code = null`, `line_status = unreadable`, `line_code_text = "ИЧ,250"`
- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`,
`line_code_text = "ИЧ,250"` и `row_index` из результата
#### Scenario: Два баланса в одном файле
- **WHEN** результат содержит два `BALANCE` (лист-продолжение другого типа или двойная печать)
- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18)
### Requirement: Признак строки, созданной правкой
Строка формы SHALL считаться созданной правкой аналитика (шаблонной, `origin = template`) тогда и только тогда, когда
`row_index IS NULL`. Маппер `1.x` и маппер `2.0` SHALL всегда писать `row_index` (у `1.x` — по порядку строк результата с
0), поэтому у строки из источника он не `null`. `line_code_original_value` SHALL хранить код строки из результата как
пришёл (`null` у `unreadable` / `missing` / `detail` и у строк, созданных правкой) и правками кода MUST NOT меняться.
Признак `line_code_original_value IS NULL` для `origin` MUST NOT использоваться.
#### Scenario: Строка источника без кода — не шаблонная
- **WHEN** у строки `unreadable` из результата `line_code_original_value = null` и `row_index = 7`
- **THEN** `origin = source`; правила шаблонных строк (удаление без сумм, 409) к ней не применяются
#### Scenario: Назначение одного кода двум строкам источника
- **WHEN** аналитик назначил код 1250 строке `unreadable` (`row_index = 7`, без сумм), а затем тот же код — строке
`missing` (`row_index = 2`) с суммой
- **THEN** обе строки `ok` с кодом 1250, ни одна не удалена, 409 нет (повтор кода законен, Р-4)
### Requirement: Шапка формы — сырой текст и уверенность
Маппер `2.0` SHALL писать в `financial_statement_meta.header_source` объект `{"<поле>": {"text": <поле>_text,
"confidence": <поле>_confidence}}` для всех полей шапки результата (`organization_name`, `inn`, `report_date`, `units`,
`okud`, `okpo`, `okopf`, `okfs`, `okei`, `address`); разобранные значения — в одноимённые колонки; `*_original_value` —
как прежде: `inn_original_value = inn`, `report_date_original_value = report_date_text`, `units_original_value =
units_text`; `organization_name_original_value = organization_name`, `inn_confidence = inn_confidence`.
`header_source` и `*_original_value` правками MUST NOT меняться. Маппер `1.x` заполняет `header_source` из полей шапки
1.x. `StatementOut.header.source` SHALL отдавать `header_source`.
#### Scenario: Подсказка у поля шапки
- **WHEN** в результате `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67`
- **THEN** `header_source.organization_name = {"text": "ООО «Ромашкa»", "confidence": 67}`; экран показывает их в подсказке
у поля
### Requirement: Агрегат формы по коду
Все чтения формы «по коду» SHALL идти через одну чистую функцию «агрегат формы по коду» без хранения (DECISION-5):
для кода и колонки — сумма `amount` строк `ok` с этим кодом (`null`, если ни одной непустой), список `line_id`,
@ -38,17 +70,24 @@
- **THEN** межформенная проверка берёт 1370 = 800
### Requirement: Условие `RECOGNIZED` при приёме 2.0
При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` ∧ в `metrics.warnings` файла
нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧ период — конец квартала (`period_year` и
`period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом (`line_code_confidence < 100`) распознанное
наименование совпало со справочным по коду ∧ в файле нет второй формы того же типа и периода; иначе `NEEDS_CHECK`.
Статус конверта и `needs_check` соседнего документа в условие MUST NOT входить (Р-19). Проверки §7, `needs_review` и
предупреждения сервиса при загрузке MUST NOT пересчитываться. Совпадение наименований: обе строки приводятся к нижнему
регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, пробелы схлопываются; совпало, если строки равны, или более
короткая (не короче 5 символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; с
любым справочным именем кода в любой редакции словаря ядра. Кода нет в словаре имён или наименование пусто — не
совпало. Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка).
`RECOGNIZED` SHALL, как и прежде, автоподтверждаться (OQ-157).
При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` и он согласован с
содержимым документа ∧ в `metrics.warnings` файла нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧
период — конец квартала (`period_year` и `period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом
(`line_code_confidence < 100`) распознанное наименование совпало со справочным по коду ∧ в файле нет второй формы того
же типа и периода (та же пара `period_year`, `period_quarter`; формы без квартала не считаются); иначе `NEEDS_CHECK`.
**Согласованность:** `done` противоречит содержимому, если есть строка `unreadable` / `missing` со значением (`amount` или
`string_value` не `null`), строка, значение или поле шапки с `needs_review`, `checks.relations_failed` или предупреждение
документа уровня `error`; тогда ядро считает документ `needs_check` и пишет `document_issue`
`RESULT_STATUS_INCONSISTENT` (`info`, источник `core`). Статус конверта и `needs_check` соседнего документа в условие
MUST NOT входить (Р-19). Проверки §7, `needs_review` и предупреждения сервиса при загрузке MUST NOT пересчитываться.
**Совпадение наименований:** обе строки приводятся к нижнему регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел,
пробелы схлопываются; совпало, если строки равны, или более короткая (не короче 5 символов) входит в более длинную, или
отношение сходства `difflib.SequenceMatcher` ≥ 0,8; сравнение — с **любым** справочным именем кода (у кода может быть
несколько имён — синонимы и законные повторы, Р-20) в любой редакции словаря ядра. Если хотя бы одно из имён начинается с
«итого по разделу», совпадение — только равенство. Кода нет в словаре имён или наименование пусто — не совпало.
Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). `RECOGNIZED` SHALL, как и прежде,
автоподтверждаться (OQ-157).
#### Scenario: Ячейка соседнего документа
- **WHEN** ББ `done`, ОФР того же файла `needs_check`, предупреждений файла уровня `error` нет, ИНН совпал, период —
@ -56,7 +95,7 @@
- **THEN** ББ `RECOGNIZED` и автоподтверждается, ОФР — `NEEDS_CHECK`
#### Scenario: Потерянный json
- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` уровня `error`
- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` (или `TABLE_PARSE_ERROR`) уровня `error`
- **THEN** все документы формы файла `NEEDS_CHECK`
#### Scenario: Подмена неуверенного кода на соседа
@ -64,6 +103,20 @@
- **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`,
`NAME_MISMATCH` у строки
#### Scenario: Законный повтор с другим именем
- **WHEN** строка 1220 (уверенность 50) с наименованием «НДС, начисленный по отгрузке», а в словаре имён `fsbu4` у 1220
есть это имя вторым
- **THEN** наименование совпало, `NAME_MISMATCH` нет
#### Scenario: Подмена итога раздела
- **WHEN** строка с кодом 1200 (уверенность 50) и наименованием «Итого по разделу I»
- **THEN** наименование не совпало со справочным «Итого по разделу II» (для «итого по разделу» — только равенство),
`NAME_MISMATCH`
#### Scenario: Сервис ошибся со статусом
- **WHEN** документ пришёл `done`, но в нём строка `unreadable` с суммой
- **THEN** документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа; автоподтверждения нет
#### Scenario: Период не конец квартала
- **WHEN** документ `done` с датой отчёта 15.08.2025
- **THEN** квартал не определён, документ `NEEDS_CHECK` (как в D-6) и не автоподтверждается; ручное подтверждение —
@ -71,16 +124,21 @@
### Requirement: Предупреждения и нарушения документа — `document_issue`
Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код,
уровень (`error` | `info`), сообщение, список `line_id`, `column_id`, поле шапки, источник (`service` | `core`), для
нарушений — текст соотношения, `expected`, `actual`. При загрузке пишутся предупреждения и `checks` результата
(источник `service`) и проверки ядра уровня `info` (источник `core`): `CODE_NOT_IN_DICTIONARY` (кода нет в словаре
имён), `CODE_NOT_IN_EDITION` (код не из перечня редакции по эффективной дате — по копии перечня `arm_statements`),
`NAME_MISMATCH`. При перезагрузке файла записи документа удаляются вместе с ним. При пересчёте после правки записи
`relation` и `soft` заменяются целиком независимо от источника; записи `warning` источника `service` сохраняются,
кроме снимаемых правкой: правка `header.report_date` снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`,
`REPORT_DATE_UNCERTAIN`; правка `header.inn` или `organization_name` — `NO_ORGANIZATION_NO_INN`; правка
`columns[].period_date` — `COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются
только перезагрузкой файла.
уровень (`error` | `info`), сообщение, список `line_id`, `column_id` (колонка формы) или `column_index` (столбец
предупреждения, которого нет среди колонок формы), поле шапки, источник (`service` | `core`); для нарушений — `codes`
(коды цели и членов), текст соотношения, `expected`, `actual`. Нарушение из `checks.relations_failed` — `kind =
relation`, `code = RELATION_FAILED`, `severity = error`; из `soft_relations_failed` — `kind = soft`, `code =
RELATION_SOFT`, `severity = info`; `line_ids` — все строки `ok` кодов `codes` в документе. При загрузке пишутся
предупреждения и `checks` результата (источник `service`) и проверки ядра уровня `info` (источник `core`):
`CODE_NOT_IN_DICTIONARY` (кода нет в словаре имён), `CODE_NOT_IN_EDITION` (код не из перечня редакции — по копии
перечня `arm_statements`), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`. При перезагрузке файла записи документа
удаляются вместе с ним. При пересчёте после правки записи `relation` и `soft` заменяются целиком независимо от источника;
записи `warning` источника `service` сохраняются, кроме снимаемых правкой: правка `header.report_date` (в том числе
подтверждение тем же значением) снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`; правка
`header.inn` или `header.organization_name` — `NO_ORGANIZATION_NO_INN`; правка `columns[].period_date` —
`COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются только перезагрузкой
файла. При удалении шаблонной строки её `id` SHALL убираться из `line_ids` всех записей документа. `GET …/statement` и
`POST …/confirm` SHALL читать нарушения §7 только из `document_issue`.
#### Scenario: Дата колонке
- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH
@ -90,107 +148,192 @@
- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения
- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует)
#### Scenario: Пропущенная колонка
- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет
- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа
### Requirement: Документы без `checks` — результаты 1.x и формы до миграции
Для формы без `checks` контракта 2.0 — принятой по результату `1.x` и существовавшей до миграции v5.19 — ядро SHALL
**один раз** посчитать §7 копией `arm_statements` по текущим суммам (`form_variant = null` — полный набор) и записать
нарушения в `document_issue` (источник `core`, виды `relation` / `soft`, как в требовании о `document_issue`): у `1.x` —
при приёме результата, у существующих форм — в миграции v5.19. Условие D-6 «проверки сошлись» для `1.x` SHALL браться из
этого расчёта. Предупреждения маппера `1.x` по документу (повтор кода, значение вне колонок) — `document_issue`
`MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — в
`recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая
форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат.
#### Scenario: Мигрированная форма с нарушением
- **WHEN** до миграции форма `NEEDS_CHECK` с несошедшимся `1600 = 1700` без акцепта; миграция v5.19 выполнена
- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]`; экран показывает
нарушение; `POST …/confirm` — 409 `CHECKS_NOT_PASSED`
#### Scenario: 1.x-документ после PATCH
- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится
- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус
пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет)
### Requirement: Пересчёт после правки
После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review` и статус тем же общим кодом
`arm_statements` по сохранённым данным (конвенция знака — по `source_text`), с правилами: `is_modified` или
`is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review`
строки = `false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят.
Отличие `statements_version` задания от версии копии ядра — `document_issue` `STATEMENTS_VERSION_MISMATCH` (`info`).
Один PATCH — один пересчёт и одна запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт сбрасывается любой правкой, как и
прежде.
`arm_statements` по сохранённым данным (конвенция знака — по `source_text`; `form_variant = null` — полный набор), с
правилами: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed`
строки ⇒ `needs_review` строки = `false`; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в голосование
знака не входят. Отличие `statements_version` задания от версии копии ядра — `document_issue`
`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Один PATCH — один пересчёт и одна
запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты
колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) акцепт MUST NOT сбрасывать.
#### Scenario: Подтверждение ячейки без правки
- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы
- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит
- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт
проверок, если был, сохраняется
### Requirement: PATCH формы — строки, колонки, подтверждение ячеек
`PATCH /documents/{id}/statement` SHALL принимать помимо `header` и `values` (сумма):
- `values[] {line_id, column_id, confirmed: true}` — подтвердить ячейку без изменения значения;
- `columns[] {column_id, period_date}` — дата колонке (пересчитывает `is_current` и период документа по правилу §9.5
п. 2); правка `header.report_date` (в том числе `null`) пересчитывает `period_end` колонок с `period_kind =
relative:*` общим кодом (`null` даёт `null`);
#### Scenario: Снятие подтверждения
- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением
- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит
### Requirement: PATCH формы — строки, колонки, подтверждение, наименование организации
`PATCH /documents/{id}/statement` SHALL принимать помимо `header {inn, report_date, units}` и `values` (сумма):
- `header.organization_name` — непустая строка до 1000 символов: `organization_name_modified = true`,
`organization_name_original_value` не меняется, пересчитывается `counterparty_check`, аудит `before/after`;
- `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты: поле уходит из
`needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит; иначе запрос без изменений — 200 без эффектов, как
прежде;
- `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения значения или снять
подтверждение;
- `columns[] {column_id, period_date}` — дата колонке (обязательная дата; `null` — 422 ошибка валидации; пересчитывает
`is_current` и период документа по правилу §9.5 п. 2); правка `header.report_date` (в том числе `null`) пересчитывает
`period_date` колонок с `period_kind = relative:*` общим кодом (`null` даёт `null`);
- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить
расшифровкой; `lines[] {line_id, confirmed: true}` — подтвердить код.
расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение.
Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail`
отметкой; `ok` → `ok` сменой кода (прежний — в `line_code_original_value`); `ok` с неуверенным кодом — подтверждением.
Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review`
строки = `false`; у `detail` `parent_row_index` пересчитывается как ближайшая строка `ok` выше по `row_index`. Код —
4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с существующим; незнакомый словарю — `info`. Шаблонная строка
с тем же кодом, созданная правкой аналитика с суммами, — 409 `LINE_EXISTS` (сначала очистить её суммы или выбрать
другой код); без сумм — удаляется, её место занимает назначенная. `line_status = ok` без кода — 422
`LINE_CODE_REQUIRED`; в одном элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого
документа — 422 `UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Правка суммы по `line_code` (для
достроенной строки) при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк
нет. Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Аудит
`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю и подтверждениям. Ограничения состояния — как
прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`).
отметкой; `ok` → `ok` сменой кода; `ok` с неуверенным кодом — подтверждением. Обратного перехода `detail` →
`unreadable` / `missing` нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла.
`line_status` принимает только `"detail"`; `"ok"` — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только
`line_code`). Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`,
`needs_review` строки = `false`; `line_code_original_value` не меняется; у `detail` `parent_row_index` пересчитывается
как ближайшая строка `ok` выше по `row_index`. Код — 4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с
существующим; незнакомый словарю — `info`. Строка с тем же кодом, **созданная правкой** (`row_index IS NULL`), с суммами —
409 `LINE_CODE_TAKEN` (сначала очистить её суммы или выбрать другой код); без сумм — удаляется (её `id` убирается из
`document_issue.line_ids`), её место занимает назначенная строка. Строки источника с тем же кодом не трогаются. В одном
элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого документа — 422
`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Правка суммы по `line_code` (для достроенной строки)
при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк источника нет. Строки,
созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Аудит
`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю, подтверждениям и наименованию организации.
Ограничения состояния — как прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`).
#### Scenario: Назначение кода строке-мусору (AURORA-1462)
- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» код 1250
- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» (`row_index = 2`) код 1250
- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`,
`line_code_original_value = null`; §7 и статус пересчитаны
`line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны
#### Scenario: Отметка расшифровкой
- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку
- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение
#### Scenario: Код занят шаблонной строкой с суммами
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика и имеет сумму
- **THEN** 409 `LINE_EXISTS`, ничего не изменено
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика (`row_index = null`) и
имеет сумму
- **THEN** 409 `LINE_CODE_TAKEN`, ничего не изменено
#### Scenario: Код занят пустой шаблонной строкой
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой и сумм не имеет
- **THEN** шаблонная строка удалена, её `id` убран из `document_issue.line_ids`; строка источника стала `ok` с кодом 1250
#### Scenario: Правка наименования организации
- **WHEN** у формы `NO_ORGANIZATION_NO_INN`, аналитик вводит `header.organization_name`
- **THEN** наименование сохранено, `organization_name_modified = true`, исходное — в `organization_name_original_value`,
`counterparty_check` пересчитан, предупреждение снято, аудит
#### Scenario: Подтверждение неуверенной даты отчёта
- **WHEN** `report_date` в `needs_review_fields`, аналитик отправляет PATCH с тем же `header.report_date`
- **THEN** `needs_review_fields` пуст, `REPORT_DATE_UNCERTAIN` снят, статус пересчитан, аудит
#### Scenario: Статус ok без кода
- **WHEN** приходит `lines: [{line_id, line_status: "ok"}]`
- **THEN** 422 `LINE_CODE_REQUIRED`
### Requirement: Подтверждение формы — порядок 409
`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять в порядке: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` →
`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing` с непустым
`amount`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле
уже подтверждена другая форма того же типа и периода; `detail.document_id`) → `CHECKS_NOT_PASSED` (жёсткие нарушения
без акцепта). Между файлами повтор формы решает автозамена §9.5 п. 3.
`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя
бы в одной ячейке `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без
значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же типа, `period_year` и
`period_quarter`; `detail.document_id`) → `CHECKS_NOT_PASSED` (записи `document_issue` вида `relation` без акцепта).
Между файлами повтор формы решает автозамена §9.5 п. 3.
#### Scenario: Неразобранная строка с суммой
- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение
- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines`
#### Scenario: Неразобранная строка только с текстом
- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`)
- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора
#### Scenario: Вторая форма того же периода
- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй
- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый
### Requirement: Словари ядра и достройка шаблонных строк
Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL удаляться: проверки ядро считает копией пакета
`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименование по
редакциям); порядок и состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды
словаря имён есть в перечне. Шаблонные строки достраиваются по словарю имён; код, у которого есть хотя бы одна строка
`ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча.
`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по
редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в
редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и
состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды словаря имён есть в
перечне. Шаблонные строки достраиваются по словарю имён (одна на код); код, у которого есть хотя бы одна строка `ok` из
файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча.
#### Scenario: Код есть только у строки unreadable
- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250»
- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом
#### Scenario: Два имени у кода
- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по
аренде»
- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность»
### Requirement: Ответ `GET …/statement` по v2.0
`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields` и сырые тексты полей; у
колонок `period_kind`, `label_confidence`; у строк `id`, `origin`, `row_index`, `source_ref`, `line_status`,
`line_code`, `line_code_text`, `line_code_confidence`, `line_name` (из файла), `reference_name` (справочное имя по
коду или `null`), `parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений
`text` (`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`,
`column_id`, `field`; `checks` — нарушения с `codes` и `line_ids`. Порядок `lines` — по коду «как на бумаге» (Р-14):
внутри строк `ok` по возрастанию кода, одинаковые коды по `row_index`; строки `unreadable` / `missing` — после
ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; `detail` — после родителя.
`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`) и
`organization_name_modified`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔
`row_index = null`), `row_index`, `source_ref`, `line_status`, `line_code`, `line_code_text`, `line_code_confidence`,
`line_name` (из файла), `reference_name` (справочное имя по коду — первое имя кода в словаре — или `null`),
`parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений `text`
(`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`,
`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`. Порядок
`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки
`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале;
`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран.
#### Scenario: Документ, принятый по 1.x
- **WHEN** форма загружена до перехода сервиса (результат 1.2)
- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, у значений `text = string_value`,
`issues` содержат прежние предупреждения маппинга, экран работает
- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, `origin = source`, у значений `text =
string_value`, `issues` содержат нарушения §7, посчитанные ядром, и `MAPPING_WARNING`; экран работает
### Requirement: Миграция DBML v5.19
Одна миграция Alembic SHALL приводить схему к DBML v5.19 и откатываться; заполнение существующих данных:
`row_index` строк — по возрастанию `line_code` внутри формы с 0, `line_status = ok`, `needs_review` строк — `false`,
`needs_review` значений — по прежнему `values_ok`, `source_text = string_value`, `is_confirmed = false`,
`period_kind` — `absolute` при `period_date`, иначе `none`, `document.file_ordinal` — по `(created_at, id)` внутри
файла с 0; `related_group.crm_id` — `null`. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`.
Одна миграция Alembic SHALL приводить схему к DBML v5.19; заполнение существующих данных: `row_index` — `NULL` у строк с
`line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию `line_code` внутри формы с 0;
`line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — по прежнему `values_ok`, `source_text =
string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant =
null`, `header_source` — из `*_original_value` и `inn_confidence`, `organization_name_original_value =
organization_name`, `document.file_ordinal` — по `(created_at, id)` внутри файла с 0, `related_group.crm_id` — `null`;
`document_issue` каждой формы — нарушения §7, посчитанные копией `arm_statements` (требование «Документы без `checks`»).
Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, совместимых с v5.18 (нет двух строк с одним кодом в
форме, строк с `line_code = null`, двух форм одного типа в файле); на несовместимых данных `downgrade` SHALL
останавливаться с ошибкой, называющей причину, и MUST NOT удалять данные молча. Сверка `check_dbml_vs_orm.py` — против
`data-model-v5.19.dbml`.
#### Scenario: Цикл миграций
- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД
- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений
#### Scenario: Строка правки до миграции
- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой
- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0
#### Scenario: Откат на данных v2.0
- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19
- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены
### Requirement: `crm_id` ГСК/ГСЗ
`related_group.crm_id` (`varchar`, nullable, уникален) SHALL сохраняться в `POST …/related-groups` и
`PATCH /directories/related-groups/{id}` и отдаваться в `RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409

View File

@ -19,9 +19,11 @@
### Requirement: Подсветка и предупреждения у места
Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по
`needs_review` строки, поля шапки — по `needs_review_fields`. Предупреждения и нарушения (`issues`, `checks`) SHALL
показываться у места: у строк (`line_ids`), ячеек (`line_ids` + `column_id`), полей шапки (`field`) или у документа;
уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized = false` напрямую нет.
`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` напрямую нет.
#### Scenario: Догадка подтверждена проверкой
- **WHEN** значение `is_recognized = false`, `needs_review = false`
@ -31,11 +33,13 @@
В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника
(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать
расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у
колонки без даты (`period_kind = none`) или с предупреждением о дате. «Сохранить» отправляет один `PATCH …/statement`
(`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами
колонки без даты (`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_EXISTS` — у поля кода.
`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или
нераспознанным текстом в ячейках.
#### Scenario: Назначить код
- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет

View File

@ -0,0 +1,44 @@
## MODIFIED Requirements
### Requirement: `make check` — быстрые проверки без Docker
Каждый сервисный репозиторий SHALL предоставлять цель `make check`, выполняющую из `api/`: `ruff check .`,
`ruff format --check .`, `mypy src` (в сервисе с копией общего кода `arm_statements` — `mypy src arm_statements`, в
`arm-core` — ещё `scripts`), `pytest -m "not integration"` (включая тесты копии `arm_statements/tests/`), тест
идентичности вендоренного контракта с `../docs/contracts` (на окне перехода между `MAJOR` — со схемой того `MAJOR`,
который сервис пишет). Цель SHALL завершаться ненулевым кодом при первой упавшей проверке и MUST NOT требовать
запущенного Docker, PostgreSQL или NATS. Свежесть копии `arm_statements` относительно репозитория `arm-statements` в
`make check` MUST NOT проверяться (соседний репозиторий в `make check` не читается).
#### Scenario: Файл не отформатирован
- **WHEN** в репозитории есть файл, не отформатированный `ruff format`
- **THEN** `make check` завершается с кодом 1 на шаге `ruff format --check`, следующие шаги не выполняются
#### Scenario: Чистый репозиторий
- **WHEN** все проверки проходят
- **THEN** `make check` завершается с кодом 0 за время, не превышающее нескольких минут, без обращения к сети
#### Scenario: Тесты копии общего кода
- **WHEN** в `tabular-parser` лежит копия `api/arm_statements/` с тестами пакета
- **THEN** `make check` гоняет их вместе с тестами сервиса, `mypy` проверяет и копию
### Requirement: `make check-full` — полная проверка против compose
Каждый сервисный репозиторий SHALL предоставлять цель `make check-full`: `make check` плюс `pytest -m integration` против
инфраструктуры `docs/dev/docker-compose.yml` (PostgreSQL 16, NATS, MinIO, для `scan-gateway` — эмулятор). Для `arm-core`
цель SHALL дополнительно выполнять «`alembic upgrade head` → `alembic downgrade base` → `alembic upgrade head`» на чистой
БД и `python scripts/check_dbml_vs_orm.py` против текущей версии модели (`../docs/model/data-model-v<текущая>.dbml`, путь — переменная `DBML` Makefile). В сервисе с копией общего
кода `arm_statements` цель SHALL выполнять `statements-check` — `../docs/scripts/sync-statements.sh --check` по этому
хосту (спека `statement-normalization`, требование «`sync-statements` — копии пакета в потребителях»): коды `0` и `10`
— шаг зелёный (`10` — с предупреждением «копия отстала»), `20` и `2` — красный. `make check-full` SHALL быть
выполнен перед созданием PR, его итог (и предупреждение `statements-check`, если было) вписывается в описание PR.
#### Scenario: Расхождение ORM и DBML
- **WHEN** в ORM добавлена колонка, которой нет в DBML
- **THEN** шаг `check_dbml_vs_orm` падает с перечнем расхождений, `make check-full` завершается с кодом 1
#### Scenario: Миграция без отката
- **WHEN** `downgrade` ревизии не удаляет созданный ею объект
- **THEN** шаг «upgrade → downgrade → upgrade» падает
#### Scenario: Прямая правка копии общего кода
- **WHEN** в `scan-gateway` поправлен файл `api/arm_statements/steps/…`, а в `arm-statements` такого изменения нет
- **THEN** `statements-check` завершается с кодом 20 и называет файл, `make check-full` красный

View File

@ -30,26 +30,32 @@ v2.0: обязательные поля `schema_version` (`"2.0"`), `job_id`, `f
Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v2.0: один файл (`stored_file`)
→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки
`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`.
Предупреждение уровня файла пишется там, где документа нет (потерянный json, таблица ИС, исключение разбора таблицы,
лист «прочее»).
`service.statements_version` — коммит копии общего кода `arm_statements`, которым посчитан результат (обязателен при
наличии `statementDocument`). `metrics.warnings[]` — предупреждения уровня файла (структура `warning` с `locator`).
Каждый документ (и `osvDocument`, и `statementDocument`) SHALL иметь `status` (`done` | `needs_check`) и `warnings[]` —
структуры `{code, severity, message, line_refs?, column_index?, field?}`; у `osvDocument` все предупреждения — `info`,
`status = done`, когда ведомость разобрана. Остальное в `osvDocument` — как в v1.2 (`extra`, `period_start`,
`period_end`, `is_from_year_start`).
структуры `{code, severity, message, line_refs?, column_index?, field?}` (`line_refs` — только `ref` строк из `lines[]`);
у `osvDocument` все предупреждения — `info`, `status = done`, когда контрольные суммы §1.4 листа сошлись, иначе
`needs_check`. Остальное в `osvDocument` — как в v1.2: у строки `rows[]` необязательное поле `extra` — объект «заголовок
колонки → строка или null» для колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»); у
шапки — `period_start`, `period_end`, `is_from_year_start`.
`statementDocument` SHALL иметь `form_variant` (`full` | `simplified` | `null`); `header` — поля `organization_name`,
`inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `address` и для каждого `<поле>_text` и
`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index`, `label`, `label_confidence`,
`inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` и для каждого `<поле>_text` и
`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index` (номер столбца в сетке кандидата: у OCR
— `column_index` ИС, у Excel — номер столбца листа с 0), `label`, `label_confidence`,
`period_date`, `period_kind` (`absolute` | `relative:current` | `relative:prev_year_end` |
`relative:prev_prev_year_end` | `relative:prev_period` | `none`); `lines[]` — `row_index`, `ref`, `line_status` (`ok` |
`relative:prev_prev_year_end` | `relative:prev_period` | `none`); `lines[]` — `row_index`, `ref` (у Excel `<лист>!<A1>`,
имя листа в кавычках по правилу Excel), `line_status` (`ok` |
`unreadable` | `missing` | `detail`), `line_code` (строка 4–5 цифр или `null`; `null` ⇔ статус не `ok`),
`line_code_text`, `line_code_confidence`, `line_name` (строка или `null`), `line_name_confidence`, `parent_ref` (у
`detail`), `needs_review`, `values[]` — `column_index`, `amount`, `string_value`, `text`, `confidence`,
`is_recognized`, `needs_review`. `checks` SHALL присутствовать у `BALANCE` / `INCOME` со строками и отсутствовать у
документа без строк; `relations_failed` и `soft_relations_failed` — `{column_index, relation, codes[], expected,
actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `INCOME` | `DISCLOSURE` | `OTHER`. `ref`
уникален в документе.
actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `INCOME` | `DISCLOSURE` | `OTHER`; у документа
без строк `columns` и `lines` — пустые массивы. `ref` уникален в документе.
#### Scenario: Результат валидируется примерами
- **WHEN** CI сервиса запускает тесты контракта
@ -64,7 +70,7 @@ actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `
- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР со строкой без кода под 2110 и пояснениями
(`examples/result-statement-excel.json`)
- **THEN** файл проходит схему v2.0: `BALANCE` с `locator` из двух листов, `status`, `checks` и мягким нарушением;
`INCOME` со строкой `detail` (`line_code = null`, `parent_ref`); `DISCLOSURE` без `columns`/`lines` и без `checks`
`INCOME` со строкой `detail` (`line_code = null`, `parent_ref`); `DISCLOSURE` с пустыми `columns`/`lines` и без `checks`
#### Scenario: Форма из OCR с сомнительными значениями
- **WHEN** scan-gateway разобрал ББ, где одна сумма собрана из грязного текста и покрыта соотношением, а одна строка
@ -87,7 +93,10 @@ actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `
Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат
с известным `MAJOR` и любым известным или новым `MINOR`. Запрос `recognition-request` остаётся v1.0; файл результата и
конверт — v2.0 (`MAJOR` поднят: `line_code` стал nullable, `metrics.warnings` и `document.warnings` — структуры, удалён
`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR).
`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR). На окне перехода схемы прежнего `MAJOR` SHALL лежать
в `docs/contracts/` рядом с текущими как `recognition-result.v1.schema.json` и `recognition-result-message.v1.schema.json`
(байтовые копии 1.2 и 1.0), их примеры — в `docs/contracts/examples/v1/`; `scripts/validate_contracts.py` проверяет оба
набора. Они удаляются тем же изменением, что удаляет маппер `1.x` ядра.
#### Scenario: Несовместимая версия запроса
- **WHEN** сервис, поддерживающий запрос `1.x`, получает запрос `schema_version = "2.0"`
@ -103,21 +112,46 @@ actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `
- **THEN** результат не проходит проверку схемы, файл получает `ERROR` с причиной в `metrics.mapping_error`, повтор не
выполняется (как для любого результата не по схеме)
### Requirement: Копия контракта в сервисе идентична источнику
Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check`
их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением).
На окне перехода между `MAJOR` тест идентичности SHALL сверять копию со схемой того `MAJOR`, который сервис пишет (или,
у ядра, читает): копия с `$id`, оканчивающимся на `/v1`, — с `docs/contracts/<имя>.v1.schema.json`, иначе — с основным
файлом `docs/contracts/<имя>.schema.json`; ядро держит копии обоих `MAJOR`. Правило теста вводится в `tabular-parser` и
`scan-gateway` до слияния контракта v2.0 в docs, иначе их `make check` упадёт на окне перехода.
#### Scenario: Копия устарела
- **WHEN** в `docs/contracts/` изменена схема, а копия в сервисе — нет
- **THEN** тест идентичности в `make check` сервиса падает с указанием имени файла
#### Scenario: Сервис ещё пишет 1.x после слияния v2.0 в docs
- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, а копия в `tabular-parser` — v1.2 (`$id` `…/v1`)
- **THEN** тест идентичности сверяет копию с `recognition-result.v1.schema.json` и проходит; посторонний PR в сервис на
окне перехода не краснеет
#### Scenario: Сервис перешёл на 2.0
- **WHEN** `scan-gateway` в PR шага 5 обновил копию до v2.0 (`$id` `…/v2`)
- **THEN** тест сверяет её с основным файлом docs
## ADDED Requirements
### Requirement: Переход на контракт v2.0 — «сначала ядро»
Ядро SHALL до перехода любого сервиса принимать конверты (включая ack `processing`) и файлы результата и `1.x`, и
`2.0`, с двумя мапперами файла результата. При приёме `1.x` статус документа формы SHALL считаться по прежнему правилу
D-6, а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате, `ref =
null`, `text = string_value`, `needs_review` значения — по прежнему `values_ok` (`is_recognized` не `false` и
`confidence` 100 или пуста), `needs_review` строки — `false`, `period_kind` — `absolute` при `period_date`, иначе `none`,
`form_variant = null`, предупреждения-строки — `warning` `{code: "LEGACY_WARNING", severity: "info", message}`.
Сервисы SHALL переходить на `2.0` только после выкладки ядра, принимающего `2.0`. Маппер `1.x` удаляется после
перехода обоих сервисов отдельным изменением.
D-6, где «проверки §7 сошлись» — по расчёту ядра копией `arm_statements` (полный набор), записанному в `document_issue`,
а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате с 0, `ref =
null`, `line_code_original_value = line_code`, `text = string_value`, `needs_review` значения — по прежнему `values_ok`
(`is_recognized` не `false` и `confidence` 100 или пуста), `needs_review` строки — `false`, `period_kind` — `absolute` при
`period_date`, иначе `none`, `form_variant = null`; строки `metrics.warnings` — в `recognition_job.metrics.warnings`
структурами `{code: "LEGACY_WARNING", severity: "info", message}`; предупреждения маппера по документу —
`document_issue` `MAPPING_WARNING` (`info`); `statements_version` задания — `null`. Сервисы SHALL переходить на `2.0`
только после выкладки ядра, принимающего `2.0`. Маппер `1.x` и схемы `*.v1.schema.json` удаляются после перехода обоих
сервисов отдельным изменением.
#### Scenario: Сервис ещё на v1.2
- **WHEN** после выкладки ядра tabular-parser присылает результат `1.2` с повтором кода и `checks.duplicate_line_codes`
- **THEN** ядро принимает его маппером `1.x`, статус документа — по D-6, файл не `ERROR`
- **THEN** ядро принимает его маппером `1.x`, обе строки кода — по прежнему правилу «остаётся первая» с
`MAPPING_WARNING`, нарушения §7 — в `document_issue` от ядра, статус документа — по D-6, файл не `ERROR`
#### Scenario: Ack нового сервиса
- **WHEN** scan-gateway после перехода публикует ack `processing` со `schema_version = "2.0"`

View File

@ -18,9 +18,14 @@ file:<имя json>;table:<i>`. `value` у OCR-ячеек всегда `null`.
### Requirement: Ошибки набора json
Ошибочный json набора (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном соседе SHALL
давать предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в `message` — код и сообщение ИС);
все json ошибочны — конверт `error` с кодом первого. Исключение при разборе таблицы — предупреждение `TABLE_PARSE_ERROR`
(`error`) документа-кандидата, остальные таблицы разбираются; ни одной таблицы не разобрано — `error
RECOGNITION_FAILED`.
все json ошибочны — конверт `error` с кодом первого. Исключение при разборе таблицы — предупреждение **файла**
`TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:<i>`; документа у таблицы нет), остальные таблицы
разбираются; ни одной таблицы не разобрано — `error RECOGNITION_FAILED`.
#### Scenario: Таблица упала при разборе
- **WHEN** разбор `table:1` json `aep_3.json` бросил исключение, `table:0` разобрана
- **THEN** документ `BALANCE` из `table:0`; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с `locator =
"file:aep_3.json;table:1"`; конверт `needs_check`; ядро не автоподтверждает документы файла
#### Scenario: Потерянный ОФР (решение № 28 среза 3a)
- **WHEN** `<job_id>.json` успешен (ББ), а `<job_id>_ofr.json` вернул `INVALID_DOCUMENT`

View File

@ -5,38 +5,80 @@
своими тестами и конфигом линтеров. Пакет SHALL содержать: типы шага 1 (`FormCandidate`, `Cell`; сериализуемые в
json dataclass-ы), шаги 2–8, общие разборы текста (двойники, даты, суммы, коды), словари — проверок §7
`statement_checks.yaml` (полный и упрощённый наборы), периодов `statement_periods.yaml`, заголовков форм и ОКУД,
подписей колонок («Код», «Наименование», исключения), букв-двойников, упорядоченные перечни кодов форм по редакциям
(`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без наименований, диапазоны кодов — и загрузчик словарей. Словаря
подписей колонок («Код», «Наименование», исключения), букв-двойников, единиц измерения `statement_units.yaml` (написания
и коды ОКЕИ), упорядоченные перечни кодов форм по редакциям (`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без
наименований — байтовая копия `docs/contracts/statement-code-orders.yaml` как `rules/statement_code_orders.yaml`,
диапазоны кодов — и загрузчик словарей. Словаря
«код → наименование» в пакете MUST NOT быть (Р-3). Пакет SHALL владеть своими типами входа и выхода; конвертацию
выхода в модель контракта делает хост. Golden-тесты пакета — внутри пакета на синтетических и обезличенных данных;
тесты на реальных фикстурах остаются в сервисах и соседние репозитории не читают.
В tabular-parser, scan-gateway и arm-core пакет SHALL лежать копией вместе с тестами и конфигом линтеров, подключённым
к `pytest` и `ruff` хоста как include, и импортироваться по имени `arm_statements`. Копию SHALL раскладывать и
проверять скрипт `sync-statements` репозитория docs (по образцу `sync-web.sh`): `--check` возвращает код 20, если
копия отличается от источника или правилась напрямую; скрипт вызывается из `make check-full` потребителя и MUST NOT
вызываться из `make check` и пайплайна платформы. Маркер коммита источника копии SHALL попадать в результат как
`service.statements_version`.
В tabular-parser, scan-gateway и arm-core пакет SHALL лежать копией вместе с тестами и конфигом линтеров и
импортироваться по имени `arm_statements`; раскладку, проверку свежести и маркер версии задаёт требование
«`sync-statements` — копии пакета в потребителях».
#### Scenario: Наименований в пакете нет
- **WHEN** ревьюер ищет в пакете соответствие «код → наименование»
- **THEN** его нет: в перечне кодов только коды, наименования строк — словарь ядра
### Requirement: `sync-statements` — копии пакета в потребителях
Копия пакета в хосте (`arm-core`, `tabular-parser`, `scan-gateway`) SHALL лежать в `api/arm_statements/` (рядом с
`api/src/`, импорт `import arm_statements`) вместе с тестами пакета (`api/arm_statements/tests/`) и его конфигом
линтеров; `ruff` и `pytest` хоста SHALL охватывать копию (тесты пакета — в `testpaths`), `mypy` хоста — запускаться и по
ней (`mypy src arm_statements`, у `arm-core` — `mypy src scripts arm_statements`). Маркер версии — файл
`api/arm_statements/VERSION` с полным хэшем коммита репозитория `arm-statements`, из которого разложена копия; его пишет
только скрипт, сервис читает при старте и пишет в результат как `service.statements_version`. Прямые правки копии MUST
NOT делаться.
Копию SHALL раскладывать и проверять скрипт `docs/scripts/sync-statements.sh` (по образцу `sync-web.sh`; цель `make -C
docs sync-statements`). Источник — локальный checkout `arm-statements` рядом с workspace или `--source <dir>`, ссылка —
`origin/main` или `--ref`; хост — `--host <имя>` или `--host-dir <dir>`. Без `--check` скрипт раскладывает копию коммита
`--ref` в новый worktree хоста на ветке фичи, пишет `VERSION` и готовит issue и PR (как `sync-web.sh`). `--check`
SHALL возвращать: `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, и это коммит `--ref`; `10` — копия
равна дереву коммита из `VERSION`, но в `--ref` есть более новый (копия отстала); `20` — копия отличается от дерева
коммита из `VERSION`, `VERSION` нет в истории источника, копия перечня кодов в пакете отличается от
`docs/contracts/statement-code-orders.yaml` или (только у `arm-core`) код словаря имён `api/rules/statement_lines.yaml`
отсутствует в перечне своей редакции; `2` — нет checkout источника или хоста, неизвестный аргумент. Файлы и коды
расхождения SHALL печататься.
Цель `statements-check` хоста (`sync-statements.sh --check --host-dir .`, docs — `../docs` или `ARM_KA_DOCS`) SHALL
входить в `make check-full` (коды `0` и `10` — зелёная, `10` с предупреждением «копия отстала», вписывается в описание PR;
`20` и `2` — красная) и MUST NOT входить в `make check` и пайплайн платформы.
#### Scenario: Прямая правка копии
- **WHEN** в `tabular-parser` изменён файл копии пакета, а источник в `arm-statements` — нет
- **THEN** `sync-statements --check` завершается с кодом 20 и называет файл; `make check` сервиса этого не проверяет
- **THEN** `sync-statements --check` завершается с кодом 20 и называет файл; `make check-full` красный; `make check`
сервиса этого не проверяет
#### Scenario: Копия отстала
- **WHEN** копия в `scan-gateway` равна коммиту `abc1234`, а в `arm-statements` `origin/main` уже `def5678`
- **THEN** `--check` завершается с кодом 10, `make check-full` зелёный с предупреждением «копия отстала»
#### Scenario: Нет checkout источника
- **WHEN** рядом нет `arm-statements` и `--source` не задан
- **THEN** `--check` завершается с кодом 2 и подсказкой, `make check-full` красный
#### Scenario: Код словаря ядра вне перечня
- **WHEN** в `arm-core/api/rules/statement_lines.yaml` редакции `fsbu4` есть код 1120, а в перечне `fsbu4` его нет
- **THEN** `--check` в `arm-core` завершается с кодом 20 и называет код и редакцию
#### Scenario: Версия общего кода в результате
- **WHEN** сервис собирает результат копией пакета, разложенной из коммита `abc1234` репозитория `arm-statements`
- **WHEN** сервис собирает результат копией пакета, у которой в `VERSION` коммит `abc1234` репозитория `arm-statements`
- **THEN** в результате `service.statements_version = "abc1234"`
### Requirement: Шаг 1 — кандидат в форму
Каждый адаптер шага 1 SHALL выдавать на файл список `FormCandidate`, и только с ним работают шаги 2–8. Поля
кандидата: `source` (`ocr` | `excel`); `locator` (`file:<имя json>;table:<i>` у OCR, `sheet:<A>[,<B>…]` у Excel);
`type_hint` (`BALANCE` | `INCOME` | `DISCLOSURE` | `null`); `evidence` (ОКУД и заголовок формы как `Cell`); `header`
(поля `organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `address` — каждое `Cell`
или `null`); `rows` — плотная сетка строк × ячеек, все страницы и листы формы уже в одной сетке, пустая позиция —
(поля `organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое
`Cell` или `null`); `rows` — плотная сетка строк × ячеек, все страницы и листы формы уже в одной сетке, пустая позиция —
`null`; `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто); `warnings`.
`Cell` SHALL быть одной для шапки и таблицы: `text` — как показывает источник; `value` — число или дата, если
источник их знает (у OCR всегда `null`; у Excel — значение по числовому формату ячейки, tp#42); `confidence` 0–100
(у Excel всегда 100); `ref` — позиция в источнике (OCR — `row:<row_index ИС>`, Excel — `<лист>!<A1>`).
(у Excel всегда 100); `ref` — позиция в источнике (OCR — `row:<row_index ИС>`, Excel — `<лист>!<A1>` по правилу Excel:
имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри
имени удваивается).
`type_hint` SHALL вычисляться одним правилом у обоих адаптеров: ОКУД по словарю (0710001 → `BALANCE`, 0710002 →
`INCOME`, прочие 0710xxx → `DISCLOSURE`) → иначе заголовок по словарю заголовков (у OCR заголовок — `label` таблицы
@ -47,6 +89,10 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв
- **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес
ячейки)
#### Scenario: Имя листа с пробелом
- **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1»
- **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"`
#### Scenario: OCR-ячейка
- **WHEN** в таблице ИС ячейка строки `row_index = 8` содержит `"[190 :"` с уверенностью 25
- **THEN** у `Cell` `text = "[190 :"`, `value = null`, `confidence = 25`, `ref = "row:8"`
@ -58,13 +104,18 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв
### Requirement: Шаг 1 — шапка кандидата
У каждого кандидата SHALL быть своя шапка. Текст каждого поля разбирается общим кодом по `Cell.text`: ИНН — только
цифры, без замены двойников, длина 10 или 12, иначе `null`; дата отчёта — словарём периодов (тот же, что для подписей
колонок, шаг 2); единицы — словарём единиц. Сырой текст и уверенность каждого поля SHALL доезжать до контракта
(`<поле>_text`, `<поле>_confidence`).
колонок, шаг 2); единицы — словарём единиц `statement_units.yaml` по тексту, а если по тексту не найдены — по коду ОКЕИ
тем же словарём (384 → «тыс. руб.», 383 → «руб.», 385 → «млн руб.»); ОКЕИ — только цифры. Сырой текст и уверенность
каждого поля SHALL доезжать до контракта (`<поле>_text`, `<поле>_confidence`).
#### Scenario: ИНН с мусором клеток
- **WHEN** поле ИНН шапки OCR — `"3|1 21 2|"` с цифрами ИНН между вертикальными чертами
- **THEN** ИНН собирается только из цифр, `|` единицей не считается; `inn_text` хранит исходный текст
#### Scenario: Единицы только кодом ОКЕИ
- **WHEN** в шапке Excel-формы нет текста единиц, а в блоке «Коды» стоит ОКЕИ 384
- **THEN** `units = "тыс. руб."`, `okei = "384"`, предупреждения `UNITS_NOT_FOUND` нет
### Requirement: Очистка OCR-текста — первая стадия типовых разборов
Очистка OCR SHALL выполняться не отдельным шагом, а первой стадией разборов даты, подписи колонки, кода строки,
разлепления склеенной строки и суммы — для любой ячейки без `value` (у Excel — только текстовые ячейки). Буквы-
@ -157,17 +208,34 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв
### Requirement: Шаг 2 — строка заголовка, нумерация и роли строк
Строка заголовка SHALL быть последней над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе
последней над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом);
двухстрочный заголовок склеивается. Строка нумерации — строка, где не меньше трёх непустых ячеек, каждая — целое число,
и числа идут подряд, начиная с 1, слева направо — SHALL получать роль `numbering` и в данные не попадать. Роль
двухстрочный заголовок склеивается. Строка нумерации SHALL получать роль `numbering` и в данные не попадать; строка —
нумерация, если в ней не меньше трёх непустых ячеек, каждая после замены букв-двойников — целое число, и числа слева
направо возрастают на 1, начинаются с 1 или 2 и допускают не больше одного пропуска. Роль
`title` — только строки выше найденной строки заголовка; в сегменте без заголовка роли `title` нет. Заголовка нет ни у
одного сегмента документа → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа,
`label = ""`, `period_end = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`).
`label = ""`, `period_date = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`).
Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки без предупреждения.
#### Scenario: Нумерация в скане (AURORA-1517)
- **WHEN** под шапкой таблицы ИС строка «1 | 2 | 3 | 4 | 5»
- **THEN** роль `numbering`, строки данных из неё нет
#### Scenario: Нумерация с двойником
- **WHEN** строка нумерации скана распознана как «1 | 2 | З | 4»
- **THEN** «З» читается как 3, роль `numbering`; строки `unreadable` с «суммами» 3 и 4 нет
#### Scenario: Нумерация без первой ячейки
- **WHEN** ИС потеряла первую ячейку нумерации: «2 | 3 | 4 | 5»
- **THEN** роль `numbering` (начало с 2 допускается)
#### Scenario: Нумерация с пропуском
- **WHEN** строка «1 | 2 | 4 | 5» (тройка не распознана)
- **THEN** роль `numbering` (один пропуск допускается); «1 | 3 | 5» — не нумерация
#### Scenario: Нумерация в Excel со «2» в столбце кодов
- **WHEN** в форме Межениновской под шапкой строка «1 | 2 | 3 | 4 | 5», где «2» стоит в столбце кодов
- **THEN** роль `numbering`; «2» кодом не считается
#### Scenario: Листы 2–5 ББ без шапки
- **WHEN** у первого листа ББ заголовок есть, у листов 2–5 — нет
- **THEN** листы 2–5 берут колонки первого, предупреждения о заголовке нет, документ может быть `done`
@ -175,7 +243,7 @@ json dataclass-ы), шаги 2–8, общие разборы текста (дв
### Requirement: Шаг 2 — подпись и дата колонки
Подпись колонки SHALL разбираться одним словарём периодов `statement_periods.yaml`, объединяющим словарь
tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dates.py::test_parses_real_forms` scan-gateway —
обязательные тесты словаря. Результат — `period_end` и `period_kind`:
обязательные тесты словаря. Результат — `period_date` (конец периода колонки) и `period_kind`:
- абсолютная подпись («На 31 марта 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «с
01.01.2025 по 31.03.2025», «2024», хвост «0:00:00», клетки «2|0|2|4|») → `absolute`;
- относительная: «На отчётную дату», «За отчётный период» → `relative:current` (= эффективная дата); «На 31 декабря
@ -185,11 +253,11 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat
- подписи нет → `none`.
Эффективная дата документа SHALL быть датой отчёта из шапки, иначе самой поздней абсолютной датой колонок; нет ни
той, ни другой → у относительных колонок `period_end = null`, `period_kind` остаётся относительным.
той, ни другой → у относительных колонок `period_date = null`, `period_kind` остаётся относительным.
Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с
предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в
столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_end = null`, `period_kind = none` и
столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_date = null`, `period_kind = none` и
предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже
100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два
года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`,
@ -198,18 +266,33 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat
#### Scenario: Бланк 66н без впечатанных дат
- **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года,
предшествующего предыдущему», дата отчёта шапки 30.09.2025
- **THEN** `period_end` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`,
- **THEN** `period_date` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`,
`relative:prev_year_end`, `relative:prev_prev_year_end`; предупреждений о дате колонок нет
#### Scenario: Мусорная подпись над числами
- **WHEN** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы
- **THEN** колонка остаётся, `period_end = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня
- **THEN** колонка остаётся, `period_date = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня
`error` с `column_index`, документ `needs_check`
#### Scenario: Колонка «Изменение»
- **WHEN** правее периодов колонка «Изменение, %»
- **THEN** колонка пропускается, предупреждение `COLUMN_SKIPPED` уровня `info`
### Requirement: Шаг 2 — номер колонки
`column_index` колонки значений (`columns[]`, `values[]`, предупреждения) SHALL быть номером столбца в сетке кандидата
шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд), у Excel — номер столбца листа первого сегмента с 0.
Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их
номера. Предупреждение о столбце, которого нет среди `columns[]` (`COLUMN_SKIPPED`), SHALL нести номер этого столбца в
сетке.
#### Scenario: Разреженные колонки ИС
- **WHEN** в таблице ИС код стоит в колонке 1, суммы — в колонках 2, 4, 5 (колонки 3 нет)
- **THEN** `columns[].column_index` — 2, 4, 5, у значений — те же номера
#### Scenario: Пропущенная колонка Excel
- **WHEN** на листе Excel коды в столбце C, периоды в D и E, «Изменение, %» в F
- **THEN** `columns[].column_index` — 3 и 4, предупреждение `COLUMN_SKIPPED` с `column_index = 5`
### Requirement: Шаг 2 — столбец наименования
Столбец наименования SHALL быть столбцом с подписью, начинающейся с «наименование» (после нормализации), в строке
заголовка; иначе — столбцом левее кода с наибольшим числом текстов, кроме столбца «Пояснения». Наименование строки —
@ -221,10 +304,11 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat
- **THEN** наименование берётся из столбца текстов статей
### Requirement: Шаг 3 — строки данных и их статусы
Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: код, число или
непустой текст в колонке значений с уверенностью ниже 100. У строки — `row_index` (порядковый номер в документе с 0,
Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: в ячейке кода есть
хотя бы одна цифра (разобранный код или нет), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`), или
непуста с уверенностью ниже 100. Строка только с текстом наименования — не строка данных. У строки — `row_index` (порядковый номер в документе с 0,
по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:<row_index ИС>`, разлепленные
`row:<i>#<k>` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!<A1>`, без столбца кодов — первой непустой
`row:<i>#<k>` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!<A1>` по правилу шага 1, без столбца кодов — первой непустой
ячейки строки), ячейка кода, `line_status`, ячейка наименования, ячейки значений по колонкам сегмента, `parent_ref`.
Статусы:
- `ok` — код разобран;
@ -241,6 +325,11 @@ tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dat
- **WHEN** ячейка кода — `"ИЧ,250"`, в колонках суммы
- **THEN** строка `unreadable` с `line_code_text = "ИЧ,250"`, суммами и `ref`; строка не пропадает
#### Scenario: Мусор в ячейке кода без значений
- **WHEN** в ББ aep_6 ячейка кода — `"44 30\nI |"`, ячейки значений пусты
- **THEN** строка `unreadable` без значений: документ `needs_check` (не все строки `ok` / `detail`), подтверждение она не
блокирует (`LINES_UNRESOLVED` — только строки со значениями)
#### Scenario: Расшифровка «в том числе»
- **WHEN** под строкой 1150 стоит строка «в том числе: Оборудование к установке» без кода с суммой
- **THEN** строка `detail`, `parent_ref` = `ref` строки 1150
@ -304,7 +393,7 @@ k-я из непустых частей, если их тоже N, иначе ц
- **WHEN** текст `"4,200"`
- **THEN** у `ocr` `amount = 4200`; у `excel` `amount = 4.20`
#### Scenario: Длинное тире (OQ-148)
#### Scenario: Длинное тире (OQ-148, закрыт решением владельца 18.10.2026)
- **WHEN** ячейка OCR — `"—"` с уверенностью 100
- **THEN** `amount = null`, `string_value = null`, `is_recognized = true`
@ -324,8 +413,8 @@ line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code`
`null` у `unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от
источника, у кода с заменой двойника — не выше 99; `line_name` — распознанный текст наименования как есть, с
уверенностью источника. Сервисы MUST NOT подставлять наименование из справочника и MUST NOT срезать уверенность кода
по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах выбирается по эффективной дате (`fsbu4` при дате после
2024-12-31) только для перечня кодов; без даты проверяются обе редакции.
по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах нужна только для перечня кодов (шаг 8): проверяются обе
редакции, эффективная дата (`fsbu4` при дате после 2024-12-31) разрешает только равенство.
#### Scenario: Наименование из OCR (AURORA-1522)
- **WHEN** у строки 1230 OCR-наименование «Дебиторская задолжен-ность»
@ -335,11 +424,13 @@ line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code`
Шаг 6 SHALL собирать `StatementDocument {document_type, form_variant, locator, header, columns, lines}`. Тип —
большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`;
совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и предупреждение
`TYPE_CONFLICT` (`error`); кодов нет — `type_hint`. Без столбца кодов: `type_hint` есть → документ без строк
(`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND` уровня `error`); `type_hint` нет → у `ocr`
таблица ИС с содержимым — предупреждение файла `TABLE_LOST` (`error`), у `excel` — лист «прочее», `SHEET_OTHER`
(`info`). `DISCLOSURE` по `type_hint` — шаги 2–7 не выполняются; `DISCLOSURE` по кодам — строки и колонки
отбрасываются, проверки не считаются, документ `done`.
`TYPE_CONFLICT` (`error`); кодов нет — `type_hint`. Поиск столбца кодов (шаг 2) SHALL выполняться всегда, в том числе
при `type_hint = DISCLOSURE`: если большинство кодов в отрезке ББ или ОФР, тип — по кодам и `TYPE_CONFLICT`. Без столбца
кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND`
уровня `error`); `type_hint` нет → у `ocr` таблица ИС с содержимым — предупреждение **файла** `TABLE_LOST` (`error`,
`locator` таблицы), у `excel` — лист «прочее», предупреждение **файла** `SHEET_OTHER` (`info`, `locator = sheet:<имя>`);
документа в обоих случаях нет. У `DISCLOSURE` (по `type_hint` без кодов форм ББ/ОФР или по кодам прочих форм) шаги 3–7
не выполняются: `columns` и `lines` — пустые массивы, `checks` нет, документ `done`.
`form_variant` SHALL определяться только по структуре кодов: ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`;
ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` — `null`. ОКУД
@ -350,6 +441,16 @@ line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code`
- **WHEN** в ББ есть 1150, 1210, 1600, 1310, 1700 и нет 1100 и 1200
- **THEN** `form_variant = simplified`; шаг 7 и перечень кодов берут упрощённый набор
#### Scenario: «Пояснения» с кодами баланса
- **WHEN** ИС подписала таблицу «Пояснения к бухгалтерскому балансу» (`type_hint = DISCLOSURE`), а в столбце кодов —
1110…1700
- **THEN** столбец кодов найден, `document_type = BALANCE`, предупреждение `TYPE_CONFLICT` уровня `error`; данные не
выброшены
#### Scenario: Лист «прочее»
- **WHEN** в книге Excel лист «Примечания» без заголовка формы, ОКУД и кодов
- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"`
#### Scenario: Заголовок спорит с кодами
- **WHEN** `type_hint = BALANCE`, а коды строк — 2110, 2120, 2100, 2200
- **THEN** `document_type = INCOME`, предупреждение `TYPE_CONFLICT` уровня `error`
@ -367,7 +468,9 @@ actual}`, где `codes` — коды цели и членов соотноше
соотношения — в `soft_relations_failed`;
- члены `abs_terms` (2120, 2210, 2220, 2330, 2350, 2410) сравниваются и как записаны, и по модулю; такое соотношение
подтверждает величину, но не знак (шаг 8);
- набор соотношений — по `form_variant` (полный или упрощённый);
- набор соотношений — по `form_variant` (полный или упрощённый); `form_variant = null` — полный. В упрощённом наборе
соотношения ББ жёсткие, а `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` ОФР — мягкое, как `2400` полного набора
(знак 2410, OQ-12);
- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из
первых четырёх цифр; «сумма подстрок = родитель» (мягкое) — только если хотя бы одна подстрока в колонке непуста.
@ -387,6 +490,11 @@ actual}`, где `codes` — коды цели и членов соотноше
- **WHEN** две строки 2410 с суммами 100 и 649
- **THEN** в соотношениях код 2410 равен 749
#### Scenario: Упрощённый ОФР с доходом по налогу
- **WHEN** у упрощённого ОФР 2410 записан со знаком дохода, и `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` по модулю
не сходится
- **THEN** нарушение в `soft_relations_failed`, документ из-за него не `needs_check`
#### Scenario: Округление итогов
- **WHEN** `1600 = 1700` расходится на 9,02 при итогах ~17 млрд
- **THEN** нарушение в `soft_relations_failed`, на статус и подсветку не влияет
@ -395,8 +503,10 @@ actual}`, где `codes` — коды цели и членов соотноше
Сервис SHALL вычислять `needs_review` у каждого значения:
- значение **нуждается в покрытии**, если `confidence < 100` или `is_recognized = false`; чистые значения в покрытии не
нуждаются;
- значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось
(в пределах допуска или округления), и `amount` значения не `null`;
- значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое
посчитано и сошлось: жёсткое — в пределах допуска или округления; мягкое — только в пределах допуска ±1, без
«округления» (Р-21; так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки — у них жёстких
соотношений нет);
- **знак**: у члена `abs_terms`, нуждающегося в покрытии, знак подтверждён, если форма записи знака (скобки, минус,
без знака — по `text`) совпадает с формой у большинства членов `abs_terms` этой колонки с непустым `amount`,
`is_recognized = true` и без правки аналитика; не совпадает или сравнивать не с чем — знак не подтверждён;
@ -418,19 +528,32 @@ actual}`, где `codes` — коды цели и членов соотноше
- **THEN** `needs_review = true` у 2120 независимо от соотношения
#### Scenario: Справочная строка без проверки
- **WHEN** значение 2510 с уверенностью 80 (строк 25xx нет в жёстких соотношениях)
- **WHEN** значение 2900 с уверенностью 80 (строк 29xx нет ни в одном соотношении)
- **THEN** `needs_review = true`
#### Scenario: Чистая прибыль подтверждена мягким соотношением (Р-21)
- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` в колонке сошлось с расхождением 0
- **THEN** у 2400 `needs_review = false`
#### Scenario: Мягкое соотношение в пределах округления не подтверждает
- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` расходится на 9 при итогах ~17 млрд (в пределах
0,001 %, но больше ±1)
- **THEN** у 2400 `needs_review = true`; нарушение в `soft_relations_failed`
### Requirement: Шаг 8 — подтверждение неуверенного кода структурой
Код строки SHALL считаться неуверенным, если `line_code_confidence < 100` или при разборе заменён двойник; только к
неуверенным кодам применяется структурная проверка (Р-12). Четырёхзначные коды строк `ok` документа образуют
последовательность; для каждой проверяемой редакции перечня (по эффективной дате; без даты — обе) берётся наибольшая
подпоследовательность, согласованная с порядком бланка (итоги после слагаемых); редакция — с более длинной
подпоследовательностью, при равенстве — по эффективной дате; уверенные коды участвуют как подтверждённые якоря;
при нескольких наибольших подпоследовательностях подтверждены только коды, общие для всех. Пятизначный код подтверждён,
если подтверждён его родитель и подстрока стоит после него. Повтор кода, у которого хотя бы одна копия неуверенна, —
`needs_review = true` у неуверенной копии (Р-16). Неподтверждённый неуверенный код — `StatementLine.needs_review = true`.
У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться.
неуверенным кодам применяется структурная проверка (Р-12). Данные — упорядоченные перечни кодов по редакциям (`66n`,
`fsbu4`) и вариантам (`full`, `simplified`), источник — `docs/contracts/statement-code-orders.yaml`; законные повторы
кода (Р-20) — часть перечня (`repeats`: у `fsbu4` 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО).
Четырёхзначные коды строк `ok` документа образуют последовательность; для **обеих** редакций берётся наибольшая
подпоследовательность, согласованная с порядком перечня варианта формы (итоги после слагаемых; код с повтором может
войти столько раз, сколько он стоит в перечне); редакция — с более длинной подпоследовательностью, при равенстве — по
эффективной дате (`fsbu4` после 2024-12-31); уверенные коды участвуют как подтверждённые якоря; при нескольких
наибольших подпоследовательностях подтверждены только коды, общие для всех. Пятизначный код подтверждён, если
подтверждён его родитель и подстрока стоит после него. Повтор кода, которого нет в перечне редакции (или копий больше,
чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review = true` у неуверенной копии (Р-16); повтор из
перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `StatementLine.needs_review =
true`. У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться.
#### Scenario: Неуверенный код на своём месте
- **WHEN** код 1230 с уверенностью 50 стоит между 1220 и 1240 (уверенными)
@ -440,6 +563,19 @@ actual}`, где `codes` — коды цели и членов соотноше
- **WHEN** порядок кодов 1210, 1230 (уверенность 50), 1220 (уверенность 50), 1240
- **THEN** у обеих неуверенных строк `needs_review = true`, документ `needs_check`
#### Scenario: Законный повтор 1220 (Р-20, AURORA-1431)
- **WHEN** в ББ печатной формы 1С по ФСБУ 4/2023 стоят подряд 1215, 1220 «НДС по приобретенным ценностям» (уверенность
100), 1220 «НДС, начисленный по отгрузке» (уверенность 50), 1230
- **THEN** обе строки 1220 подтверждены структурой (повтор есть в перечне `fsbu4`), `needs_review = false`
#### Scenario: Повтор вне перечня
- **WHEN** в ББ код 1230 стоит дважды подряд, у второй копии уверенность 50
- **THEN** у второй копии `needs_review = true`, документ `needs_check`
#### Scenario: Бланк 66н в 2025 году
- **WHEN** скан ББ за 3 кв. 2025 на бланке 66н с неуверенным кодом 1120 между 1110 и 1130
- **THEN** проверяются обе редакции, подпоследовательность `66n` длиннее, код 1120 подтверждён
#### Scenario: Нестандартный код Excel
- **WHEN** в Excel-форме код 1231 или пятизначная подстрока 11101 вне перечня
- **THEN** `needs_review` строки не ставится; проверка «код не из редакции» — `info` ядра
@ -447,8 +583,11 @@ actual}`, где `codes` — коды цели и членов соотноше
### Requirement: Шаг 8 — поля шапки
Дата отчёта с уверенностью ниже 100 SHALL подтверждаться совпадением с самой поздней абсолютной датой колонок; иначе
поле `report_date` попадает в `needs_review_fields` и выдаётся `REPORT_DATE_UNCERTAIN` (`error`, `field =
report_date`). ИНН и организация с уверенностью ниже 100 — предупреждения `INN_UNCERTAIN` / `ORGANIZATION_UNCERTAIN`
(`info`, с `field`), в `needs_review_fields` не попадают; единицы и прочие поля на статус не влияют.
report_date`). У бланка только с относительными подписями колонок абсолютных дат нет, и неуверенная дата отчёта
структурой не подтверждается никогда: документ `needs_check`, аналитик подтверждает или исправляет дату в ядре (PATCH
шапки). `needs_review_fields` — поля, которые держат документ в `needs_check`; ИНН и организация с уверенностью ниже 100
— предупреждения `INN_UNCERTAIN` / `ORGANIZATION_UNCERTAIN` (`info`, с `field`), в `needs_review_fields` не попадают
(подсветка у поля — по предупреждению); единицы и прочие поля на статус не влияют.
#### Scenario: Неуверенная, но верная дата
- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, самая поздняя дата колонок 30.09.2025
@ -458,16 +597,25 @@ report_date`). ИНН и организация с уверенностью ни
- **WHEN** дата отчёта 30.09.2023 с уверенностью 60, колонки на 30.09.2025 и 31.12.2024
- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`, документ `needs_check`
#### Scenario: Бланк 66н с относительными подписями
- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, подписи колонок — «На отчетную дату», «На 31 декабря предыдущего
года»
- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`; снимается подтверждением даты
аналитиком
### Requirement: Шаг 8 — предупреждения
Предупреждение SHALL быть структурой `{code, severity, message, line_refs?, column_index?, field?}`: `severity` —
`error` | `info`; `line_refs` — список `ref` строк; `column_index` — колонка; `field` — поле шапки. Место в файле —
`locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно имеют `locator?`
(json набора ИС или лист). Уровни SHALL быть такими:
`error` | `info`; `line_refs` — список `ref` строк только из `lines[]` (о строках ролей `title` / `header` /
`numbering` — в `message`); `column_index` — номер столбца сетки (требование «Шаг 2 — номер колонки»); `field` — поле
шапки. Место в файле — `locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно
имеют `locator?` (json набора ИС, таблица ИС или лист) и SHALL выдаваться там, где документа нет; предупреждение файла
уровня `error` делает конверт `needs_check` и блокирует `RECOGNIZED` всех документов файла в ядре. Уровни SHALL быть
такими:
| Код | Уровень | Когда |
|---|---|---|
| `JSON_FAILED` | error (файл) | json набора ИС ошибочен при успешном соседе |
| `TABLE_PARSE_ERROR` | error | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`) |
| `TABLE_PARSE_ERROR` | error (файл) | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`); документа нет |
| `TABLE_LOST` | error (файл) | таблица ИС с содержимым без колонки кодов и без `type_hint` |
| `CODE_COLUMN_NOT_FOUND` | error | у формы с `type_hint` `BALANCE`/`INCOME` нет столбца кодов |
| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в том числе лист-продолжение) |
@ -491,12 +639,17 @@ report_date`). ИНН и организация с уверенностью ни
| `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) |
| `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны |
| `VALUE_OUT_OF_RANGE` | info | число Excel длиннее 16 целых цифр |
| `SHEET_OTHER` | info | лист Excel «прочее» без `type_hint` и кодов |
| `SHEET_OTHER` | info (файл) | лист Excel «прочее» без `type_hint` и кодов; документа нет |
| `ROW_SPLIT` | info | разлеплена склеенная строка |
Предупреждения пути ОСВ — уровня `info`, код с префиксом `OSV_`. Новый код предупреждения вводится словарём пакета и
таблицей §11 `05-domain-rules.md`; `code` в контракте — строка `^[A-Z][A-Z0-9_]*$`, не перечисление.
#### Scenario: Исключение при разборе таблицы ИС
- **WHEN** разбор второй таблицы json упал с исключением, первая разобрана
- **THEN** документ первой таблицы со своим статусом; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с
`locator = "file:<имя json>;table:1"`; конверт `needs_check`
#### Scenario: НПЗ ОФР без ИНН
- **WHEN** в шапке ОФР есть организация и нет ИНН
- **THEN** `INN_NOT_FOUND` уровня `info`, документ может быть `done`
@ -511,8 +664,13 @@ report_date`). ИНН и организация с уверенностью ни
период определён (дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет
значений, строк и полей шапки с `needs_review` ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет
предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и
проверках. Функция SHALL принимать на вход и подтверждения и акцепт аналитика (у ядра после правок): правленое или
подтверждённое значение и строка не имеют `needs_review`, акцепт снимает жёсткие нарушения. Статус конверта: `done` ⇔
проверках. Вход функции SHALL быть одинаковым у сервиса и ядра и не зависеть от источника: `document_type`,
`layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть строка из источника,
`row_index` не `null`), `has_organization_or_inn`, `period_known`, строки `{line_status, needs_review}`, значения
`{needs_review}`, число жёстких нарушений вне допуска и округления, `accepted`, уровни предупреждений документа (у ядра —
записи `document_issue` вида `warning`). `needs_review` на входе уже учитывает правки и подтверждения аналитика
(правленое или подтверждённое значение и строка — `false`); `accepted` — жёсткие нарушения не учитываются. Статус ОСВ —
`done`, когда контрольные суммы §1.4 листа сошлись. Статус конверта: `done` ⇔
все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error`
(`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser). Квартал периода, сверка ИНН и наименований
со справочником в `done` MUST NOT входить.
@ -525,6 +683,11 @@ report_date`). ИНН и организация с уверенностью ни
- **WHEN** в форме есть строка `unreadable` с суммой
- **THEN** документ `needs_check`
#### Scenario: Ядро считает статус той же функцией
- **WHEN** ядро после PATCH вызывает функцию статуса по строкам БД формы, у которой все строки `ok`, правленая ячейка
была `needs_review`, нарушений и предупреждений `error` нет
- **THEN** функция даёт `done` — тот же результат, что дал бы сервис по тем же данным с подтверждённой ячейкой
#### Scenario: Соседний документ не тянет файл
- **WHEN** ББ `done`, ОФР того же файла `needs_check` из-за одной ячейки, предупреждений файла нет
- **THEN** конверт `needs_check`, у ББ `document.status = done`

View File

@ -7,10 +7,13 @@ tabular-parser SHALL оставить у себя чтение книги (ли
выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без
кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах
(организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`;
заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!<A1>`; дописывать
заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!<A1>` (имя листа в одинарных
кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать
листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа,
диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в
`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`).
`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без
признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`),
документа нет.
#### Scenario: Книга ББ + ОФР
- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР»
@ -19,7 +22,7 @@ tabular-parser SHALL оставить у себя чтение книги (ли
#### Scenario: Ссылки ячеек
- **WHEN** код строки стоит в ячейке C27 листа «Баланс»
- **THEN** `ref` строки — `Баланс!C27`
- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27`
### Requirement: Строки без кода и неизвестные колонки в Excel
tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее
@ -30,9 +33,23 @@ tabular-parser SHALL отдавать строки без кода с содер
- **WHEN** в ОФР ФСБУ 4/2023 строка «Реализация иного имущества…» без кода с суммами стоит под строкой 2340
- **THEN** в результате строка `detail` с `parent_ref` строки 2340; ранее она отбрасывалась со счётчиком
### Requirement: Манифест ожиданий на реальных формах
Манифест `tabular-parser/api/tests/statements_manifest.yaml` SHALL быть обновлён в PR перехода на 2.0 по правилу
«посчитанного» соотношения §7 (пустая цель при непустых членах — нарушение): `failed_relations` страниц 2 баланса без
1600 получают `["1600 = 1700"]` — `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`,
`unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только
актив» (`Б.Б Лист 1.xls`, `ББ. Лист 1 .xls`, `ББ. Лист 1 3кв.25.xls`) сохраняют `["1600 = 1700"]` (теперь как «непустая
цель при пустых членах»). Статусы `done` (Межениновская ББ и ОФР, НПЗ ББ и ОФР, ОФР «Лист 1» Унагранде ×2) MUST NOT
меняться: их строки без кода уходят в `detail`, строки шапки выше заголовка — в `title`. Остальные записи манифеста
пересчитываются прогоном и правятся только с объяснением в описании PR.
#### Scenario: Страница 2 баланса
- **WHEN** разбирается `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (есть 1700, нет 1600)
- **THEN** документ `needs_check`, в `checks.relations_failed` — `1600 = 1700` с пустой целью
### Requirement: Путь ОСВ в контракте v2.0
У `osvDocument` tabular-parser SHALL выдавать `status` (`done`, когда ведомость разобрана, иначе `needs_check` — по
прежним условиям §1.6 листа) и `warnings[]` — прежние предупреждения листа структурами с `severity = info` и кодом с
У `osvDocument` tabular-parser SHALL выдавать `status` (`done`, когда контрольные суммы §1.4 листа сошлись, иначе
`needs_check` — прежние условия §1.6, применённые к листу) и `warnings[]` — прежние предупреждения листа структурами с `severity = info` и кодом с
префиксом `OSV_`; строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Остальной разбор ОСВ не меняется.
#### Scenario: Предупреждение ОСВ

View File

@ -1,9 +1,12 @@
#!/usr/bin/env python3
"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12.
"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12 и упорядоченных перечней кодов форм.
Запуск из любого cwd: python scripts/validate_contracts.py
Зависимости: стандартная библиотека + jsonschema.
Зависимости: стандартная библиотека + jsonschema + PyYAML (`make venv`).
Код возврата: 0 — всё валидно, 1 — любая ошибка.
Окно перехода на MAJOR 2 (change unified-statement-normalization): схемы прежнего MAJOR лежат рядом как
`*.v1.schema.json`, их примеры — в `examples/v1/`; удаляются после перехода обоих сервисов (маппер 1.x ядра — тоже).
"""
import datetime
import json
@ -21,8 +24,16 @@ SCHEMAS = {
"request": CONTRACTS / "recognition-request.schema.json",
"message": CONTRACTS / "recognition-result-message.schema.json",
"result": CONTRACTS / "recognition-result.schema.json",
"message.v1": CONTRACTS / "recognition-result-message.v1.schema.json",
"result.v1": CONTRACTS / "recognition-result.v1.schema.json",
}
# Каталог примеров → суффикс вида схемы: examples/ — текущий MAJOR, examples/v1/ — прежний (окно перехода).
EXAMPLE_DIRS = [(EXAMPLES, ""), (EXAMPLES / "v1", ".v1")]
CODE_ORDERS = CONTRACTS / "statement-code-orders.yaml"
CODE_RE = re.compile(r"^\d{4,5}$")
# Порядок важен: result-message-* проверяется раньше result-*.
PATTERNS = [
("request-", "request"),
@ -75,9 +86,11 @@ def main():
print(f"OK схема {path.name}")
counts = {kind: 0 for kind in SCHEMAS}
for example in sorted(EXAMPLES.glob("*.json")):
kind = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None)
if kind is None:
examples = [(example, suffix) for folder, suffix in EXAMPLE_DIRS for example in sorted(folder.glob("*.json"))]
for example, suffix in examples:
base = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None)
kind = None if base is None else (base if base == "request" else base + suffix)
if kind is None or kind not in SCHEMAS:
print(f"FAIL {example.name}: имя не подходит ни под один шаблон (request-*, result-message-*, result-*)")
failed = True
continue
@ -107,10 +120,67 @@ def main():
print(f"FAIL нет ни одного примера для схемы {SCHEMAS[kind].name}")
failed = True
failed = check_code_orders() or failed
total = sum(counts.values())
print(f"Итого: примеров {total}, {'есть ошибки' if failed else 'всё валидно'}")
return 1 if failed else 0
def code_order_errors(data):
"""Структура перечня: editions[id] → forms[BALANCE|INCOME] → variants[full|simplified] → список кодов."""
errors = []
if not isinstance(data, dict) or not isinstance(data.get("editions"), dict):
return ["нет словаря editions"]
for edition in ("66n", "fsbu4"):
forms = data["editions"].get(edition)
if not isinstance(forms, dict):
errors.append(f"нет редакции {edition}")
continue
for form, first in (("BALANCE", "1"), ("INCOME", "2")):
variants = forms.get(form)
if not isinstance(variants, dict):
errors.append(f"{edition}: нет формы {form}")
continue
for variant in ("full", "simplified"):
codes = variants.get(variant)
where = f"{edition}/{form}/{variant}"
if not isinstance(codes, list) or not codes:
errors.append(f"{where}: пустой или не список")
continue
for code in codes:
if not isinstance(code, str) or not CODE_RE.fullmatch(code) or not code.startswith(first):
errors.append(f"{where}: код {code!r} — не строка из 4 цифр формы")
repeats = {c for i, c in enumerate(codes) if c in codes[:i]}
allowed = set(data.get("repeats", {}).get(edition, {}).get(form, []))
if repeats - allowed:
errors.append(f"{where}: повтор кода вне перечня repeats: {sorted(repeats - allowed)}")
return errors
def check_code_orders():
"""Упорядоченные перечни кодов форм (05-domain-rules.md §11.9): yaml разбирается и устроен как описано."""
if not CODE_ORDERS.is_file():
print(f"FAIL {CODE_ORDERS.name}: файл не найден")
return True
try:
import yaml
except ImportError:
print(f"FAIL {CODE_ORDERS.name}: нужен PyYAML (make venv)")
return True
try:
data = yaml.safe_load(CODE_ORDERS.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
print(f"FAIL {CODE_ORDERS.name}: не YAML: {str(exc).splitlines()[0]}")
return True
errors = code_order_errors(data)
if errors:
print(f"FAIL {CODE_ORDERS.name}")
for error in errors:
print(f" {error}")
return True
print(f"OK {CODE_ORDERS.name}")
return False
if __name__ == "__main__":
sys.exit(main())