Столбец без подписи правее последней колонки с подписью периода колонкой сумм не считается; вместо 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>
268 lines
24 KiB
Markdown
268 lines
24 KiB
Markdown
# 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` без переменной и закоммитить эталоны вместе с кодом.
|
||
Реальные названия организаций, ИНН и адреса во входах запрещены — только обезличенные.
|
||
|