Andrey Epifancev 547f9efcdb feat(#5): шаг 2 — числа правее последней подписанной колонки не колонки сумм
Столбец без подписи правее последней колонки с подписью периода колонкой сумм
не считается; вместо COLUMN_WITHOUT_DATE — info NUMBERS_OUTSIDE_TABLE с адресами
ячеек. Решение владельца 18.10.2026 (arm-ka-docs#116, Р-31).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:07 +04:00

268 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# arm-statements
Общий код нормализации форм ББ/ОФР (пакет `arm_statements`) для tabular-parser, scan-gateway и arm-core.
Раскладывается копией в `api/arm_statements/` хостов скриптом `sync-statements` из arm-ka-docs.
Спека: arm-ka-docs, `openspec/changes/unified-statement-normalization/`.
Копия раскладывается `make -C docs sync-statements`; прямые правки копии в хостах запрещены.
## Как проверить
```bash
make venv && make check
```
## Публичный API
Импорт — `from arm_statements import …`. Всё, что не перечислено, — внутреннее (может меняться без координации с
хостами). Все dataclass-ы — `frozen=True, slots=True`; последовательности — `tuple`. Раздел сведён с запросами планов
3, 4, 5 (18.10.2026, P1-21…P1-28): хосты не повторяют правил пакета у себя — ни столбца кодов, ни нормализации меток, ни
перевода в контракт.
### Перечисления (`StrEnum`, значение — строка контракта)
| Имя | Значения |
|---|---|
| `Source` | `OCR = "ocr"`, `EXCEL = "excel"` |
| `DocumentType` | `BALANCE`, `INCOME`, `DISCLOSURE` |
| `FormVariant` | `FULL = "full"`, `SIMPLIFIED = "simplified"` |
| `Edition` | `RAS_66N = "66n"`, `FSBU4 = "fsbu4"` |
| `PeriodKind` | `ABSOLUTE = "absolute"`, `CURRENT = "relative:current"`, `PREV_YEAR_END = "relative:prev_year_end"`, `PREV_PREV_YEAR_END = "relative:prev_prev_year_end"`, `PREV_PERIOD = "relative:prev_period"`, `NONE = "none"` |
| `LineStatus` | `OK = "ok"`, `UNREADABLE = "unreadable"`, `MISSING = "missing"`, `DETAIL = "detail"` |
| `Severity` | `ERROR = "error"`, `INFO = "info"` |
| `DocStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"` |
| `EnvelopeStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"`, `ERROR = "error"` |
| `SignForm` | `PARENTHESES = "parentheses"`, `MINUS = "minus"`, `UNSIGNED = "unsigned"` |
| `WarningCode` | по члену на код словаря `statement_warnings.yaml`, имя = значение: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `CODE_COLUMN_NOT_FOUND`, `SEGMENT_WITHOUT_CODES`, `SHEET_NOT_JOINED`, `CONTINUATION_TYPE_CONFLICT`, `SEGMENT_COLUMNS_MISMATCH`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`, `HEADER_ROW_NOT_FOUND`, `TYPE_CONFLICT`, `NO_ORGANIZATION_NO_INN`, `PERIOD_UNKNOWN`, `REPORT_DATE_UNCERTAIN`, `XLSB_SCALE_SUSPECT`, `INN_NOT_FOUND`, `INN_UNCERTAIN`, `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN`, `REPORT_DATE_UNPARSED`, `UNITS_NOT_FOUND`, `FILE_TYPE_MISMATCH`, `COLUMN_SKIPPED`, `FRACTIONAL_IN_THOUSANDS`, `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED`, `VALUE_OUT_OF_RANGE`, `SHEET_OTHER`, `ROW_SPLIT`, `NUMBERS_OUTSIDE_TABLE`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») |
### Вход шага 1 (строит адаптер хоста)
```python
CellValue = Decimal | date | bool | None
Cell(text: str, value: CellValue, confidence: float, ref: str)
# text — как показывает источник; value — у OCR всегда None, у Excel — по числовому формату (Decimal), дата или bool;
# confidence 0–100 (Excel — 100); ref — OCR "row:<row_index ИС>", Excel "<лист>!<A1>" по правилу Excel.
# ref ячеек шапки (HeaderCells) и evidence пакет только читает: в выход шагов 2–8 он не переносится (у HeaderField
# поля ref нет), формат — на усмотрение адаптера (scan-gateway — "data_ocr.<поле ИС>", P5-5)
Evidence(okud: Cell | None = None, title: Cell | None = None)
# title.text без изменений становится StatementHeader.form_name (пустой после strip — None)
HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None)
HEADER_FIELDS: tuple[str, ...] # имена полей HeaderCells по порядку
StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (),
column_index: int | None = None, field: str | None = None, locator: str | None = None)
FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells,
rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (),
warnings: tuple[StatementWarning, ...] = ())
# rows: позиция ячейки в строке = номер столбца сетки (OCR — column_index ИС, Excel — столбец листа с 0);
# locator: "file:<имя json>;table:<i>" | "sheet:<A>[,<B>…]"; warnings — предупреждения адаптера документу
# (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …)
```
### Выход шагов 2–8
```python
HeaderField(text: str | None, confidence: float | None, value: str | None)
DateField(text: str | None, confidence: float | None, value: date | None)
StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField,
okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField,
address: HeaderField, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ())
# контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence; form_name — header.form_name
# (заголовок формы как в источнике, из evidence.title кандидата; не теряется при normalize)
StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None,
period_kind: PeriodKind)
StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None,
confidence: float, is_recognized: bool, needs_review: bool = False)
StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None,
line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None,
parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...])
# values — по всем columns документа, в их порядке
FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...],
expected: Decimal | None, actual: Decimal | None)
# сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key.
# expected is None ⇔ цель пуста, actual is None ⇔ пусты все члены (контракт: money, null — «цели нет»); такое
# посчитанное соотношение — всегда нарушение (спека «Шаг 7»), P1-21
StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...])
StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str,
header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...],
checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...])
# checks is None ⇔ lines == (); form_variant is None ⇔ DISCLOSURE (у BALANCE/INCOME без строк — FULL, P1-19);
# status == document_status(status_input(document)) всегда
CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...])
NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...])
```
### Функции конвейера и разборов
```python
normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult
# шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER);
# при этом candidate.warnings адаптера пакет не переносит — хост обрабатывает их сам
normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult
# по порядку; у кандидата без документа candidate.warnings не попадают в результат (см. выше);
# исключение кандидата не перехватывается — хост, которому нужна изоляция кандидатов
# (TABLE_PARSE_ERROR), зовёт normalize_candidate в цикле
recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult
parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader # form_name = None (ставит конвейер)
type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None
is_continuation(text: str | None, rules: Rules) -> bool # маркер «Форма 0710001 с.2» (любой ОКУД)
continuation_type(text: str | None, rules: Rules) -> DocumentType | None # тип маркера; не маркер или ОКУД не из словаря — None
sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int,
form_amount_columns: int, rules: Rules) -> bool # лист без маркера и шапки (адаптер Excel плана 4)
code_column(rows: Sequence[Sequence[Cell | None]], rules: Rules, *, relaxed: DocumentType | None = None) -> int | None
# столбец кодов сетки — правило (а)/(б) шага 2 (с защитой от блока «Коды»); relaxed=BALANCE|INCOME — ослабленный
# поиск в отрезке этой формы, минимум rules.code_ranges.min_codes_continuation; relaxed=DISCLOSURE — ValueError
amount_columns(rows: Sequence[Sequence[Cell | None]], code_col: int, rules: Rules, *,
source: Source = Source.EXCEL) -> tuple[int, ...]
# столбцы правее code_col, где в строках с кодом есть число — колонки сегмента без заголовка; «число колонок сумм»
# для sheet_joins — len(amount_columns(...))
parse_code(text: str | None, rules: Rules) -> CodeParse # CodeParse(code: str | None, fixed: bool)
parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse
# AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None)
parse_date(text: str | None, rules: Rules) -> date | None
resolve_relative(kind: PeriodKind, effective: date | None) -> date | None
# дата относительной колонки от эффективной даты (ядро — после правки даты отчёта); effective is None → None;
# ABSOLUTE и NONE → None, не исключение (P1-24)
choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, rules: Rules,
*, variant: FormVariant | None = None) -> Edition | None
# редакция формы без полного пересчёта (ядро — при приёме результата 2.0): codes — (код, уверенный) строк ok по
# порядку строк, уверенный ⇔ confidence == 100 или код зафиксирован аналитиком; variant None → по кодам;
# лучшая подпоследовательность перечня (больше уверенных, затем длиннее), ничья — по дате, ничья без даты — None;
# DISCLOSURE и пустые codes — по дате. recompute отдаёт ту же редакцию (P1-25)
fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str
normalize_caption(text: str | None) -> str # подписи колонок: нижний регистр, ё→е, всё кроме букв, цифр и % → пробел
normalize_label(text: str | None) -> str # метки шапки и заголовки: = normalize_text tabular-parser (P1-22)
make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None,
field: str | None = None, locator: str | None = None) -> StatementWarning
# code — WarningCode (или строка того же значения); уровень — из словаря statement_warnings.yaml; неизвестный код —
# KeyError (предупреждение с выдуманным кодом не создаётся). Хосты создают JSON_FAILED, TABLE_PARSE_ERROR,
# FILE_TYPE_MISMATCH, XLS_NO_CODEPAGE, XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT,
# SHEET_NOT_JOINED, SHEET_OTHER только через неё
to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение (golden)
candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate # обратная для кандидата
statements_version(path: Path | None = None) -> str | None # содержимое api/arm_statements/VERSION копии
load_rules(directory: Path | None = None) -> Rules # RulesError при ошибке словаря
default_rules() -> Rules # кэш словарей пакета
```
`Rules` и `RulesError` входят в `__all__`: это тип результата `load_rules` / `default_rules` и ошибка словаря (хостам нужны для аннотаций и обработки `RulesError`); атрибуты `Rules`, доступные хостам, перечислены в разделе «Словари (`Rules`)» ниже.
`CodeParse` и `AmountParse` в `__all__` не входят (возвращаются функциями; при необходимости аннотаций —
`arm_statements.codes.CodeParse`, `arm_statements.amounts.AmountParse`).
### Форма контракта v2.0 (сервисы пишут, ядро читает)
```python
to_contract(document: StatementDocument) -> dict[str, Any]
# statementDocument схемы v2.0: шапка плоская (form_name, <поле>, <поле>_text, <поле>_confidence для всех
# HEADER_FIELDS, needs_review_fields), деньги — строки format(x, "f") или null, у нарушений нет column_key,
# checks нет ⇔ строк нет, предупреждения — warning_to_contract без locator. Хост валидирует dict своей моделью
warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any]
# {code, severity, message} + непустые line_refs / column_index / field; locator — только file_level=True
# (metrics.warnings); null у необязательных ключей не пишется — схема его не допускает
status_input_from_contract(document: Mapping[str, Any]) -> StatusInput
# вход функции статуса по statementDocument контракта (ядро: страховка document.status результата 2.0);
# status_input_from_contract(to_contract(d)) == status_input(d)
```
### Статус
```python
StatusLine(line_status: LineStatus, needs_review: bool)
StatusValue(line_status: LineStatus, needs_review: bool) # line_status — статус строки значения
StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool,
needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...],
hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...])
# needs_review на входе уже учитывает правки и подтверждения; значения строк DETAIL функция игнорирует
status_input(document: StatementDocument) -> StatusInput
# layout_found = строки есть; period_known = дата отчёта или колонка absolute с датой; hard_failures =
# len(checks.relations_failed); accepted = False — сервис и ядро собирают вход одним правилом
document_status(data: StatusInput) -> DocStatus
envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus
# ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост
```
### Пересчёт по сохранённым данным (ядро)
```python
StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind)
StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None,
is_recognized: bool, value_fixed: bool)
# text — source_text (у значения правки None); value_fixed — is_modified или is_confirmed
StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None,
line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None,
values: tuple[StoredValue, ...])
# row_index None — строка правки (в §7 и варианте — пока есть сумма; в структуре и родителях detail — нет);
# code_fixed — line_code_modified или is_confirmed строки
StoredWarning(code: str, severity: Severity)
StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool,
period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool,
warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...])
RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks,
line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool],
detail_parents: Mapping[str, str | None], status: DocStatus)
# value_needs_review: ключ (StoredLine.key, StoredValue.column_key); detail_parents — только строки DETAIL;
# checks.*.expected / actual — Decimal | None, как у сервиса
```
### Словари (`Rules`) — что нужно хостам
Хосты читают словари только через эти атрибуты; прочие поля `Rules` — внутренние.
```python
Rules.code_orders.sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...]
# перечень кодов бланка с законными повторами (ядру — порядок и состав строк формы); DISCLOSURE → ();
# variant — FormVariant, не None: у формы без варианта хост передаёт FormVariant.FULL (как набор §7)
Rules.code_orders.editions -> tuple[Edition, ...] # порядок редакций перечня: (Edition.RAS_66N, Edition.FSBU4)
Rules.checks.tolerance -> Decimal # Decimal("1") — допуск ±1 (ядру — межформенная проверка)
Rules.code_ranges.min_codes -> int # 3 — «три и более кодов» (столбец кодов, sheet_joins)
Rules.code_ranges.min_codes_continuation -> int # 1 — минимум кодов слабого продолжения (code_column relaxed, sheet_joins)
Rules.headers.labels -> Mapping[str, tuple[re.Pattern[str], ...]]
# ключи ровно: organization, inn, okpo, okopf, okfs, okopf_okfs, okei, units, address, date (LABEL_KEYS)
Rules.headers.form_titles / okud -> Mapping[DocumentType, tuple[re.Pattern[str], ...]]
Rules.headers.okud_code -> re.Pattern[str]
Rules.headers.continuation / auditor / legal_form -> tuple[re.Pattern[str], ...]
Rules.headers.code_caption / name_caption / explanations_caption -> re.Pattern[str]
Rules.headers.column_exclusions -> tuple[re.Pattern[str], ...]
Rules.periods.relative -> tuple[tuple[PeriodKind, re.Pattern[str]], ...]
Rules.lookalikes -> Mapping[str, str] # буквы-двойники
Rules.warnings -> Mapping[str, WarningRule] # WarningRule(code, severity, scope: "document" | "file")
```
Какой текст подаётся шаблонам (`re.search`), — правило словаря, хост его не выбирает (P1-22):
| Нормализация | Ключи |
|---|---|
| `normalize_label(text)` — пунктуация сохраняется | `headers.form_titles`, `okud`, `okud_code`, `continuation`, `labels.*`, `auditor`, `legal_form` |
| `normalize_caption(text)` — пунктуация → пробел | `headers.code_caption`, `name_caption`, `explanations_caption`, `column_exclusions`, `units.spellings`, `periods.relative` |
## Как менять словари
Словари пакета — данные в `arm_statements/rules/*.yaml`, правка идёт PR сюда, затем `make -C docs sync-statements` в
хостах («сначала пакет»).
- `statement_code_orders.yaml` — **байтовая копия** `docs/contracts/statement-code-orders.yaml` (arm-ka-docs); руками не
правится. Перечень кодов меняется только PR в docs, затем копия переносится сюда без изменений.
- Новый код предупреждения — три правки: строка в `rules/statement_warnings.yaml` (уровень и область), член `WarningCode`
в `arm_statements/issues.py` (загрузчик требует равенства множеств), строка таблицы §11.9 `05-domain-rules.md` в docs и
таблица спеки «Шаг 8 — предупреждения».
- Новый синоним метки шапки, заголовка, подписи колонки — в соответствующий `statement_*.yaml` под нормализацию своего
ключа (таблица «ключ словаря → нормализация» в разделе «Публичный API»).
- Новое соотношение §7 — строка в `statement_checks.yaml`; прочая логика не меняется.
- После правки словаря: `make check`; golden-эталоны, которые изменились осознанно, пересобираются `make golden-update`.
## golden-update
Эталоны `arm_statements/tests/golden/*.expected.json` — `to_jsonable` результата `normalize_candidate` для пяти
обезличенных входов (`*.candidate.json`), закрепляющих сочетания, которые сценарии спеки описывают по одному.
Без переменной тест сравнивает с эталоном. Пересборка:
```bash
make golden-update # GOLDEN_UPDATE=1 pytest arm_statements/tests/test_golden.py
```
Тест переписывает эталоны и падает с напоминанием. Дальше: просмотреть `git diff` эталонов глазами против Review Focus
плана 1 (каждое изменение — осознанное), запустить `make check` без переменной и закоммитить эталоны вместе с кодом.
Реальные названия организаций, ИНН и адреса во входах запрещены — только обезличенные.