diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index c98b8a1..887e931 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -1542,6 +1542,7 @@ III (aep_3). Алгоритм: четырёхзначные коды строк | `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок | PATCH `header.report_date` | | `UNITS_NOT_FOUND` | info | единицы не найдены | — | | `FILE_TYPE_MISMATCH` | info, файл | формат файла не совпал с `file_type` | — | +| `XLS_NO_CODEPAGE` | info, файл | книга xls без кодовой страницы — кириллица могла прочитаться неверно (формы и ОСВ книги) | — | | `COLUMN_SKIPPED` | info | подпись из словаря исключений пропущена | — | | `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) | — | | `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны | — | diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md index 4e2aeb5..c31f00e 100644 --- a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md @@ -1,6 +1,6 @@ # План 1 — пакет общего кода `arm_statements` (репозиторий `arm-statements`). План реализации -> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 19 задач: по +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 20 задач: по > `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet; исполнители задач — sonnet: готового кода > в плане нет, есть сигнатуры, тесты и правила спеки; финальное ревью ветки — opus). Шаги размечены чекбоксами (`- [ ]`). @@ -85,7 +85,7 @@ GNU make, Gitea (`tea`), git worktree. ## Review Focus Входы с реальных файлов (fixtures aep_3, НПЗ, выгрузки 1С), которые сценарии спеки описывают по одному, а встретятся -вместе. Каждый пункт закреплён тестом задачи 19 на обезличенной копии внутри пакета (`arm_statements/tests/golden/`): +вместе. Каждый пункт закреплён тестом задачи 20 на обезличенной копии внутри пакета (`arm_statements/tests/golden/`): 1. **Мусорные коды aep_3 в одной таблице ББ** — «НИ», «изо :», «| 140» среди нормальных кодов: «| 140» → строка `ok` 1140 с неуверенным кодом, «НИ» и «изо :» → строки `detail` с `parent_ref` ближайшей `ok`, ни одна строка не пропала, @@ -117,8 +117,8 @@ arm-statements/ ├── model.py # перечисления и dataclass-ы входа и выхода ├── serialize.py # to_jsonable(), candidate_from_dict() ├── loader.py # Rules, load_rules(), default_rules(), RulesError - ├── issues.py # make_warning() - ├── text.py # буквы-двойники, нормализация подписей, «цифра под видом буквы» + ├── issues.py # WarningCode, make_warning() + ├── text.py # буквы-двойники, normalize_caption / normalize_label, «цифра под видом буквы» ├── codes.py # разбор кода, отрезки кодов, присоединение листа ├── dates.py # parse_date, подпись колонки → период, ожидаемые даты ├── amounts.py # parse_amount, build_value (шаг 4, ячейка) @@ -130,11 +130,12 @@ arm-statements/ ├── lines.py # шаги 4–5: StatementLine, предупреждения единиц ├── evaluation.py # EvalLine / EvalValue — общий вид строк для шагов 6–8 ├── checks.py # шаг 7: §7, вариант формы - ├── structure.py # шаг 8: перечни кодов, редакция, подтверждение кода + ├── structure.py # шаг 8: перечни кодов, choose_edition(), подтверждение кода ├── review.py # шаг 8: needs_review значений и строк, поля шапки, evaluate() - ├── status.py # функция статуса документа и конверта + ├── status.py # функция статуса документа и конверта, status_input(документ) ├── pipeline.py # шаг 6 и normalize_candidate() / normalize() ├── recompute.py # StoredForm → RecomputeResult + ├── contract.py # to_contract(), warning_to_contract(), status_input_from_contract() — форма контракта v2.0 ├── rules/ │ ├── statement_checks.yaml # §7: полный и упрощённый наборы │ ├── statement_periods.yaml # подписи периодов: относительные, текстовые, числовые @@ -213,13 +214,13 @@ def test_version_empty_file_is_none(tmp_path: Path) -> None: `Makefile` (рецепты из корня, окружение `.venv`, `BIN := $(CURDIR)/.venv/bin`): `venv` — `uv venv --python 3.11 --allow-existing .venv` и `uv pip install --python .venv/bin/python -r pyproject.toml --extra dev`; `lint` — `ruff check .`, `ruff format --check .`, `mypy arm_statements`; `test` — `pytest`; `check: lint test`; `golden-update` — -`GOLDEN_UPDATE=1 $(BIN)/pytest arm_statements/tests/test_golden.py` (тест задачи 19). `help` с описанием целей. +`GOLDEN_UPDATE=1 $(BIN)/pytest arm_statements/tests/test_golden.py` (тест задачи 20). `help` с описанием целей. `.gitignore`: шаблон Python (как у scan-gateway) + `arm_statements/VERSION`, `.codegraph/`, `.superpowers/`, `.bounded`. `conftest.py`: пока пустой модуль с docstring (фикстура `rules` появится в задаче 3). `README.md`: назначение пакета, ссылка на спеку, «копия раскладывается `make -C docs sync-statements`, прямые правки копии запрещены», «как проверить: -`make venv && make check`»; раздел «Публичный API» дописывается в задаче 19. +`make venv && make check`»; раздел «Публичный API» дописывается в задаче 20. - [ ] **Step 3: Run test to verify it fails** @@ -281,7 +282,8 @@ git -C "$R" commit -m "feat: каркас пакета arm_statements, make chec - `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, needs_review_fields: tuple[str, ...] = ())` + address: HeaderField, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ())` — `form_name` — + заголовок формы как в источнике (`header.form_name` контракта; ставит конвейер задачи 17 из `evidence.title`) - `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, @@ -290,7 +292,9 @@ git -C "$R" commit -m "feat: каркас пакета arm_statements, make chec 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, ...])` - `FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...], - expected: Decimal, actual: Decimal)` — `column_key`: у сервиса `str(column_index)`, у ядра — ключ колонки хоста + expected: Decimal | None, actual: Decimal | None)` — `column_key`: у сервиса `str(column_index)`, у ядра — ключ + колонки хоста; `expected is None` ⇔ цель пуста (нет строки `ok` цели с `amount`), `actual is None` ⇔ пусты все + члены (контракт: `expected`/`actual` — `money`, `null` — «цели нет»; 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, ...], @@ -394,7 +398,9 @@ git -C "$R" commit -m "feat: типы шага 1 и выхода шагов 2– - `class RulesError(ValueError)` — словарь не прошёл проверку (сообщение называет файл и ключ) - `class CodeSection(StrEnum)`: `BALANCE`, `INCOME`, `CAPITAL`, `CASH_FLOW`, `NOTES`, `TARGET_USE` - `CodeRange(section: CodeSection, low: int, high: int)`; `CodeRanges(ranges: tuple[CodeRange, ...], - min_share: Decimal, min_codes: int)` + min_share: Decimal, min_codes: int, min_codes_continuation: int)` — `min_codes_continuation`: минимум кодов + ослабленного поиска столбца (сегмент-продолжение, спека «Шаг 2 — сегменты…»: «минимум один») и листа-продолжения без + маркера (`sheet_joins`) - `RelationRule(form: DocumentType, variant: FormVariant, relation: str, target: str, terms: tuple[tuple[int, str], ...], strict: bool, abs_terms: frozenset[str])` — `terms`: `(+1 | -1, код)` - `CheckRules(tolerance: Decimal, rounding_tolerance_percent: Decimal, subline_check: bool, @@ -408,21 +414,30 @@ git -C "$R" commit -m "feat: типы шага 1 и выхода шагов 2– tuple[re.Pattern[str], ...]], okud_code: re.Pattern[str], continuation: tuple[re.Pattern[str], ...], code_caption: re.Pattern[str], name_caption: re.Pattern[str], explanations_caption: re.Pattern[str], column_exclusions: tuple[re.Pattern[str], ...], labels: Mapping[str, tuple[re.Pattern[str], ...]], - auditor: tuple[re.Pattern[str], ...], legal_form: tuple[re.Pattern[str], ...])` + auditor: tuple[re.Pattern[str], ...], legal_form: tuple[re.Pattern[str], ...])` — ключи `labels` ровно + `LABEL_KEYS = ("organization", "inn", "okpo", "okopf", "okfs", "okopf_okfs", "okei", "units", "address", "date")`; + какая нормализация текста к какому ключу применяется — раздел «Публичный API пакета», «Словари» - `UnitRules(spellings: tuple[tuple[re.Pattern[str], str], ...], okei: Mapping[str, str], thousands: str, fractional_share_percent: int)` - `CodeOrders(editions: tuple[Edition, ...], sequences: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]])` с методом `sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> - tuple[str, ...]`; `editions` — порядок редакций в файле (`(RAS_66N, FSBU4)`) + tuple[str, ...]`; `editions` — порядок редакций в файле (`(RAS_66N, FSBU4)`); `sequence(…, DocumentType.DISCLOSURE, + …)` → `()` (у пояснений перечня нет, структурная проверка к ним не применяется) - `WarningRule(code: str, severity: Severity, scope: Literal["document", "file"])` - `Rules(checks: CheckRules, periods: PeriodRules, headers: HeaderRules, units: UnitRules, code_ranges: CodeRanges, code_orders: CodeOrders, lookalikes: Mapping[str, str], warnings: Mapping[str, WarningRule])` - `load_rules(directory: Path | None = None) -> Rules`; `default_rules() -> Rules` (`functools.cache`, каталог `Path(__file__).parent / "rules"`) -- Produces (`arm_statements/issues.py`): `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` — уровень из `rules.warnings[code]`; неизвестный код — `KeyError`. +- Produces (`arm_statements/issues.py`): + - `class WarningCode(StrEnum)` — по члену на код словаря `statement_warnings.yaml` (имя = значение = код: + `WarningCode.JSON_FAILED == "JSON_FAILED"`); хосты передают в `make_warning` член перечисления, а не строку-литерал + - `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` — уровень из + `rules.warnings[code]`; `code` — `WarningCode` или строка (`StrEnum` — подкласс `str`); неизвестный код — + `KeyError` с текстом «код предупреждения не задан в statement_warnings.yaml» (предупреждение с выдуманным кодом + не создаётся) - Produces (`conftest.py`): фикстура `rules() -> Rules` (`default_rules()`). +- Produces (`loader.py`): `LABEL_KEYS: tuple[str, ...]` — ключи `headers.labels` в порядке словаря. Содержимое словарей (ключи фиксированы; значения — из доноров и спеки): @@ -452,26 +467,32 @@ git -C "$R" commit -m "feat: типы шага 1 и выхода шагов 2– `'тысяч\w*\s+рубл'`), `млн руб.` (`'млн\w*\.?\s*руб'`, `'миллион\w*\s+рубл'`), `руб.` (`'руб'`); `okei: {"384": "тыс. руб.", "383": "руб.", "385": "млн руб."}`; `thousands: "тыс. руб."`; `fractional_share_percent: 10`. - `statement_code_ranges.yaml`: `sections: {BALANCE: [1100, 1799], INCOME: [2100, 2999], CAPITAL: [3100, 3699], - CASH_FLOW: [4100, 4499], NOTES: [5000, 5999], TARGET_USE: [6100, 6499]}`, `min_share: 0.6`, `min_codes: 3`. + CASH_FLOW: [4100, 4499], NOTES: [5000, 5999], TARGET_USE: [6100, 6499]}`, `min_share: 0.6`, `min_codes: 3`, + `min_codes_continuation: 1` (из `line_codes.min_codes_continuation` tabular-parser). - `statement_code_orders.yaml`: `cp` из П-5. -- `statement_warnings.yaml`: `warnings: {: {severity, scope}}` — ровно таблица спеки (тест ниже). +- `statement_warnings.yaml`: `warnings: {: {severity, scope}}` — ровно таблица спеки «Шаг 8 — предупреждения» + (тест ниже), в том числе `XLS_NO_CODEPAGE: {severity: info, scope: file}` (книга xls без кодовой страницы, P1-23). - `ocr_lookalikes.yaml`: `lookalikes:` — `О о O o → 0`, `З з → 3`, `б → 6`, `S s → 5`, `I l | і → 1`, `Ч → 4`. Проверки загрузчика (`RulesError`): каждая регулярка компилируется; `relation` — `<4 цифры> = <4 цифры> ([+-] <4 цифры>)*`, `abs_terms ⊆` членов; `variant ∈ {full, simplified}`; `code_orders.version == 1`; в каждой редакции и форме в `full` каждый код встречается `1 + (число его вхождений в repeats)` раз, в `simplified` — не чаще; все коды перечня — -четыре цифры; `severity ∈ {error, info}`, `scope ∈ {document, file}`; у `units.okei` значения есть среди `canonical`. +четыре цифры; `severity ∈ {error, info}`, `scope ∈ {document, file}`; множество кодов `statement_warnings.yaml` равно +множеству `WarningCode`; ключи `headers.labels` — ровно `LABEL_KEYS`; `1 ≤ min_codes_continuation ≤ min_codes`; у +`units.okei` значения есть среди `canonical`. - [ ] **Step 1: Write the failing test** ```python import re +from decimal import Decimal from pathlib import Path import pytest import yaml -from arm_statements.loader import RulesError, Rules, load_rules +from arm_statements.issues import WarningCode, make_warning +from arm_statements.loader import LABEL_KEYS, RulesError, Rules, load_rules from arm_statements.model import DocumentType, Edition, FormVariant, Severity RULES_DIR = Path(__file__).parent.parent / "rules" @@ -491,7 +512,7 @@ SPEC_WARNINGS = { "FILE_TYPE_MISMATCH": ("info", "file"), "COLUMN_SKIPPED": ("info", "document"), "FRACTIONAL_IN_THOUSANDS": ("info", "document"), "XLSB_NO_FORMATS": ("info", "document"), "NUMBER_FORMAT_UNPARSED": ("info", "document"), "VALUE_OUT_OF_RANGE": ("info", "document"), - "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"), + "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"), "XLS_NO_CODEPAGE": ("info", "file"), } @@ -514,6 +535,21 @@ def test_code_orders_with_repeats(rules: Rules) -> None: ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL) assert "1120" in ras and ras.index("1110") < ras.index("1100") assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4) + assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == () + + +def test_public_rule_types(rules: Rules) -> None: + assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1") + assert all(isinstance(e, Edition) for e in rules.code_orders.editions) + fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED) + assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu) + assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3) + assert tuple(rules.headers.labels) == LABEL_KEYS + + +def test_warning_code_enum_matches_dictionary(rules: Rules) -> None: + assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS) + assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE" def test_no_code_to_name_mapping_anywhere() -> None: @@ -553,10 +589,23 @@ def test_unknown_severity_rejected(tmp_path: Path) -> None: def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None: - from arm_statements.issues import make_warning + warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5) + assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5) + assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR - warning = make_warning("COLUMN_SKIPPED", "колонка «Изменение, %» пропущена", rules, column_index=5) - assert (warning.severity, warning.column_index) == (Severity.INFO, 5) + +def test_make_warning_unknown_code_raises(rules: Rules) -> None: + with pytest.raises(KeyError, match="NOT_A_CODE"): + make_warning("NOT_A_CODE", "x", rules) + + +def test_label_keys_are_checked(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + target = tmp_path / "statement_headers.yaml" + target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8") + with pytest.raises(RulesError, match="statement_headers"): + load_rules(tmp_path) ``` - [ ] **Step 2: Run test to verify it fails** @@ -599,7 +648,13 @@ git -C "$R" commit -m "feat: словари нормализации (§7, пе - `is_disguised_digit(text: str, lookalikes: Mapping[str, str]) -> bool` — после `fix_lookalikes(anywhere=True)` есть цифра и нет других букв (`'I'`, `'| О'` → `True`; `'Косарев'`, `'(расшифровка подписи)'` → `False`) - `normalize_caption(text: str | None) -> str` — нижний регистр, `ё → е`, всё, кроме букв, цифр и `%`, → пробел, - пробелы схлопнуты + пробелы схлопнуты, по краям обрезано; `None` → `""`. Под неё записаны подписи колонок (`headers.code_caption`, + `name_caption`, `explanations_caption`, `column_exclusions`), `units.spellings` и `periods.relative` + - `normalize_label(text: str | None) -> str` — нормализация меток шапки и заголовков, дословно `normalize_text` + tabular-parser: удалить невидимые `U+00AD`, `U+200B`, `U+FEFF`; нижний регистр; `ё → е`; любые пробельные символы + (включая NBSP и переносы строк), `-`, `U+2010…U+2015`, `U+2212` подряд → один пробел; по краям обрезано; `None` → + `""`. Пунктуация (точки, скобки, кавычки, `/`) **сохраняется**. Под неё записаны `headers.form_titles`, `okud`, + `okud_code`, `continuation`, `labels`, `auditor`, `legal_form` (P1-22) - Produces (`arm_statements/codes.py`): - `CodeParse(code: str | None, fixed: bool)` — `fixed`: при разборе заменён двойник - `parse_code(text: str | None, rules: Rules) -> CodeParse` — однострочный текст по спеке «Диапазоны кодов и разбор @@ -615,7 +670,7 @@ git -C "$R" commit -m "feat: словари нормализации (§7, пе - `sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, form_amount_columns: int, rules: Rules) -> bool` — правило листа без маркера и шапки (спека «Шаг 1 — многостраничная форма»): все коды в отрезке формы и при трёх и более кодах число колонок сумм совпадает; кодов нет - — `False` (для адаптера Excel плана 3) + — `False`; кодов меньше `code_ranges.min_codes_continuation` — `False` (для адаптера Excel плана 4) - [ ] **Step 1: Write the failing test** @@ -625,7 +680,7 @@ import pytest from arm_statements.codes import is_code_cell, majority_section, parse_code, sheet_joins from arm_statements.loader import CodeSection, Rules from arm_statements.model import DocumentType -from arm_statements.text import fix_lookalikes, is_disguised_digit, normalize_caption +from arm_statements.text import fix_lookalikes, is_disguised_digit, normalize_caption, normalize_label @pytest.mark.parametrize( @@ -676,6 +731,19 @@ def test_caption_normalization() -> None: assert normalize_caption("Наименование\nпоказателя") == "наименование показателя" +def test_label_normalization_keeps_punctuation(rules: Rules) -> None: + assert normalize_label("Местонахождение (адрес)") == "местонахождение (адрес)" + assert normalize_label("по ОКОПФ / ОКФС") == "по окопф / окфс" + assert normalize_label("Форма\u00a00710001 с.2") == "форма 0710001 с.2" + assert normalize_label("Отчёт о финансовых\nрезультатах") == "отчет о финансовых результатах" + assert normalize_label("за\u00adдол\u00adжен\u2014ность") == "задолжен ность" + assert normalize_label(None) == "" + for key, patterns in rules.headers.labels.items(): + assert patterns, key + assert any(p.search(normalize_label("Местонахождение (адрес)")) for p in rules.headers.labels["address"]) + assert any(p.search(normalize_label("по ОКОПФ / ОКФС")) for p in rules.headers.labels["okopf_okfs"]) + + def test_majority_section_and_join(rules: Rules) -> None: assert majority_section(["1110", "1150", "2110"], rules) is CodeSection.BALANCE assert sheet_joins(DocumentType.INCOME, ["2400"], 1, 3, rules) @@ -724,11 +792,12 @@ git -C "$R" commit -m "feat: буквы-двойники, нормализаци - `parse_period_label(cell: Cell | None, rules: Rules) -> PeriodLabel` — `None`/пустой текст → `(NONE, None, False, False)`; `Cell.value` — `date` → `ABSOLUTE` с этой датой; иначе `fix_lookalikes(text)`; исключение (`column_exclusions` по `normalize_caption`) → `excluded=True`, `kind=NONE`; относительная подпись (словарь - `relative`, по порядку) → её `kind`, `date=None`; иначе `parse_date` → `ABSOLUTE`; иначе `NONE`. `bare_year` — текст + `relative` по `normalize_caption` очищенного текста, по порядку) → её `kind`, `date=None`; иначе `parse_date` → `ABSOLUTE`; иначе `NONE`. `bare_year` — текст после очистки — четыре цифры года - `resolve_relative(kind: PeriodKind, effective: date | None) -> date | None` — `CURRENT` → `effective`; `PREV_YEAR_END` → 31.12 года − 1; `PREV_PREV_YEAR_END` → 31.12 года − 2; `PREV_PERIOD` → `effective` − 1 год - (29.02 → 28.02); `effective is None` или не относительный вид → `None` + (29.02 → 28.02); `effective is None` → `None`; `ABSOLUTE` и `NONE` → `None` (не исключение: у таких колонок + дата не вычисляется — хост берёт её из подписи или от аналитика, P1-24) - `expected_dates(effective: date) -> frozenset[date]` — `effective`, она же − 1 и − 2 года (29.02 → 28.02), 31.12 года − 1 и года − 2 @@ -804,6 +873,7 @@ def test_resolve_relative_and_expected() -> None: assert resolve_relative(PeriodKind.PREV_PREV_YEAR_END, eff) == date(2023, 12, 31) assert resolve_relative(PeriodKind.PREV_PERIOD, date(2024, 2, 29)) == date(2023, 2, 28) assert resolve_relative(PeriodKind.CURRENT, None) is None + assert resolve_relative(PeriodKind.ABSOLUTE, eff) is None and resolve_relative(PeriodKind.NONE, eff) is None assert expected_dates(eff) == frozenset( {eff, date(2024, 9, 30), date(2023, 9, 30), date(2024, 12, 31), date(2023, 12, 31)} ) @@ -983,17 +1053,19 @@ git -C "$R" commit -m "feat: parse_amount по стадиям (ядро числ `headers.okud_code`; `organization_name`, `address` — текст без пробелов по краям (пусто → `None`); `needs_review_fields = ()` - `type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None` — ОКУД по словарю - `headers.okud` → иначе заголовок по `headers.form_titles` (текст нормализуется: нижний регистр, `ё → е`, дефисы и - переносы → пробел) → иначе `None` - - `continuation_type(text: str | None, rules: Rules) -> DocumentType | None` — маркер «Форма 0710001 с.2» - (`headers.continuation`) → тип по ОКУД из маркера; не маркер — `None` (для адаптера Excel плана 3) + `headers.okud` → иначе заголовок по `headers.form_titles` (оба — `search` по `normalize_label(text)`) → иначе `None` + - `is_continuation(text: str | None, rules: Rules) -> bool` — текст — маркер продолжения «Форма 0710001 с.2»: хотя + бы один шаблон `headers.continuation` находится в `normalize_label(text)` (для адаптера Excel плана 4: маркер без + ОКУД из словаря — всё равно маркер) + - `continuation_type(text: str | None, rules: Rules) -> DocumentType | None` — `is_continuation` → тип по ОКУД из + маркера (`headers.okud`); не маркер **или** маркер без известного ОКУД — `None` (для адаптера Excel плана 4) - [ ] **Step 1: Write the failing test** ```python from datetime import date -from arm_statements.header import continuation_type, parse_header, type_hint +from arm_statements.header import continuation_type, is_continuation, parse_header, type_hint from arm_statements.loader import Rules from arm_statements.model import DocumentType from arm_statements.tests.builders import hdr @@ -1032,6 +1104,12 @@ def test_type_hint_okud_wins(rules: Rules) -> None: def test_continuation_marker(rules: Rules) -> None: assert continuation_type("Форма 0710002 с.2", rules) is DocumentType.INCOME assert continuation_type("Бухгалтерский баланс", rules) is None + assert is_continuation("Форма\u00a00710000 с. 3", rules) and continuation_type("Форма 0710000 с. 3", rules) is None + assert not is_continuation("Бухгалтерский баланс", rules) and not is_continuation(None, rules) + + +def test_type_hint_uses_label_normalization(rules: Rules) -> None: + assert type_hint(None, "ОТЧЁТ О ФИНАНСОВЫХ\nРЕЗУЛЬТАТАХ", rules) is DocumentType.INCOME ``` - [ ] **Step 2: Run test to verify it fails** @@ -1069,7 +1147,13 @@ git -C "$R" commit -m "feat: разбор шапки формы (ИНН без - `find_code_column(rows: Grid, first: int, last: int, rules: Rules, *, relaxed_section: CodeSection | None = None) -> int | None` — правило (а)/(б) спеки «Шаг 2 — столбец кодов» («под подписью» — ячейки столбца ниже строки подписи до `last`); `relaxed_section` задан — ослабленный поиск: - столбец с наибольшим числом кодов этого отрезка, минимум один (ничья — левый столбец) + столбец с наибольшим числом кодов этого отрезка, минимум `rules.code_ranges.min_codes_continuation` (ничья — левый + столбец) + - `code_column(rows: Grid, rules: Rules, *, relaxed: DocumentType | None = None) -> int | None` — **публичная** + обёртка для адаптеров: `find_code_column(rows, 0, len(rows) - 1, rules, relaxed_section=…)` над всей сеткой (пустая + сетка — `None`); `relaxed` — `BALANCE` / `INCOME` → отрезок `CodeSection` этого типа, `DISCLOSURE` → `ValueError` + (у пояснений нескольких отрезков). Одно правило с шагом 2: адаптер Excel берёт им вид листа, границу шапки и столбец + для `sheet_joins` - `first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rules) -> int | None` - `is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool` — ≥ 3 непустых ячеек, каждая после `fix_lookalikes(anywhere=True)` — целое, слева направо +1, начало 1 или 2, не больше одного пропуска (скачок +2) @@ -1092,11 +1176,13 @@ git -C "$R" commit -m "feat: разбор шапки формы (ИНН без ```python from decimal import Decimal as D +import pytest + from arm_statements.layout import ( - find_code_column, find_header_rows, find_name_column, first_code_row, is_numbering_row, segment_roles, + code_column, find_code_column, find_header_rows, find_name_column, first_code_row, is_numbering_row, segment_roles, ) from arm_statements.loader import CodeSection, Rules -from arm_statements.model import Cell, RowRole +from arm_statements.model import Cell, DocumentType, RowRole from arm_statements.tests.builders import excel, ocr, place BALANCE_ROWS = [ @@ -1133,6 +1219,15 @@ def test_relaxed_search_finds_single_code(rules: Rules) -> None: assert [find_code_column(cand.rows, 0, 0, rules, relaxed_section=CodeSection.INCOME)] == [2] +def test_public_code_column(rules: Rules) -> None: + assert code_column(ocr(BALANCE_ROWS).rows, rules) == 1 + single = ocr([["Чистая прибыль", "", "2400", "7", "6"]]).rows + assert (code_column(single, rules), code_column(single, rules, relaxed=DocumentType.INCOME)) == (None, 2) + assert code_column(single, rules, relaxed=DocumentType.BALANCE) is None and code_column((), rules) is None + with pytest.raises(ValueError, match="DISCLOSURE"): + code_column(single, rules, relaxed=DocumentType.DISCLOSURE) + + def test_numbering_rows(rules: Rules) -> None: def row(*texts: str) -> tuple[Cell | None, ...]: return ocr([list(texts)]).rows[0] @@ -1207,6 +1302,10 @@ git -C "$R" commit -m "feat: шаг 2 — столбец кодов с защи колонка сегмента до сопоставления; `date` абсолютной подписи (у относительной `None`) - `has_numbers(rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules) -> bool` — в строках с кодом есть ячейка с `value`-числом или с `parse_amount(...).amount is not None` + - `amount_columns(rows: Grid, code_col: int, rules: Rules, *, source: Source = Source.EXCEL) -> tuple[int, ...]` — + **публичная**: столбцы правее `code_col` по возрастанию, где `has_numbers` по строкам сетки с ячейкой-кодом + (`is_code_cell`) в `code_col`; то же правило, что колонки сегмента без заголовка (`segment_columns` берёт его же) и + «число колонок сумм» для `sheet_joins` (`len(...)`) - `segment_columns(rows: Grid, header_rows: tuple[int, ...], data_rows: Sequence[int], code_col: int, source: Source, rules: Rules) -> tuple[list[ColumnDraft], list[StatementWarning]]` — сегмент с заголовком: столбцы правее кода; подпись (склейка строк заголовка через пробел; уверенность — минимум) → период — колонка; исключение @@ -1231,7 +1330,7 @@ git -C "$R" commit -m "feat: шаг 2 — столбец кодов с защи from datetime import date from decimal import Decimal as D -from arm_statements.columns import document_dates, finalize_columns, segment_columns +from arm_statements.columns import amount_columns, document_dates, finalize_columns, segment_columns from arm_statements.header import parse_header from arm_statements.loader import Rules from arm_statements.model import PeriodKind, Severity, Source @@ -1307,6 +1406,13 @@ def test_segment_without_header(rules: Rules) -> None: drafts, warnings = segment_columns(cand.rows, (), (0, 1), 1, OCR, rules) assert [(d.grid_col, d.label, d.kind) for d in drafts] == [(2, "", PeriodKind.NONE), (4, "", PeriodKind.NONE)] assert warnings == [] + + +def test_public_amount_columns(rules: Rules) -> None: + rows = [["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", "Изменение"], + ["Запасы", "", "1210", D("5"), D("4"), "рост"], ["Итого", "", "1200", D("5"), "", "рост"]] + assert amount_columns(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4) + assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 4) ``` - [ ] **Step 2: Run test to verify it fails** @@ -1710,9 +1816,10 @@ git -C "$R" commit -m "feat: шаги 4–5 — строки формы с сы `SIMPLIFIED`; иначе `FULL`; `DISCLOSURE` → `None` - `subline_parents(codes: Sequence[str]) -> list[str | None]` — для каждой позиции: у пятизначного кода — ближайший четырёхзначный выше с теми же первыми тремя цифрами, иначе первые четыре цифры; у прочих — `None` - - `RelationOutcome(column: str, rule: RelationRule, codes: tuple[str, ...], computed: bool, expected: Decimal, - actual: Decimal, within_tolerance: bool, within_rounding: bool)` со свойством `hard_failed: bool` (`rule.strict and - computed and not within_tolerance and not within_rounding`) + - `RelationOutcome(column: str, rule: RelationRule, codes: tuple[str, ...], computed: bool, expected: Decimal | None, + actual: Decimal | None, within_tolerance: bool, within_rounding: bool)` со свойством `hard_failed: bool` + (`rule.strict and computed and not within_tolerance and not within_rounding`); `expected is None` — цель пуста, + `actual is None` — пусты все члены (P1-21) - `evaluate_relations(lines: Sequence[EvalLine], columns: Sequence[str], document_type: DocumentType, variant: FormVariant | None, rules: Rules) -> list[RelationOutcome]` — строки — в порядке документа (вызывающий сортирует; от порядка зависят родители подстрок); набор `relations_for(document_type, @@ -1720,7 +1827,9 @@ git -C "$R" commit -m "feat: шаги 4–5 — строки формы с сы колонке, где хоть одна подстрока непуста); значение кода — сумма `amount` по строкам `is_check_line`; посчитано — непуста цель или член; правая часть как записано и с `abs_terms` по модулю, берётся сошедшийся вариант, иначе ближайший (при равенстве — «как записано»); `within_tolerance`: `|expected − actual| ≤ tolerance`; - `within_rounding`: `|expected − actual| ≤ max(|expected|, |значения членов|) × rounding_tolerance_percent / 100` + `within_rounding`: `|expected − actual| ≤ max(|expected|, |значения членов|) × rounding_tolerance_percent / 100`; + посчитанное соотношение с `expected is None` или `actual is None` — `within_tolerance = within_rounding = False` + (спека: «пустая цель при непустых членах и непустая цель при пустых членах — нарушение»; пусто ≠ 0) - `checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks` — `hard_failed` → `relations_failed`; посчитанное и не сошедшееся в пределах допуска прочее → `soft_relations_failed`; `codes` — цель и члены; `relation` — текст правила @@ -1761,6 +1870,7 @@ def test_empty_target_is_violation(rules: Rules) -> None: line(None, "100", status=LineStatus.DETAIL)] checks = failed(lines, BAL, FormVariant.FULL, rules) assert [f.relation for f in checks.relations_failed] == ["1600 = 1100 + 1200"] + assert (checks.relations_failed[0].expected, checks.relations_failed[0].actual) == (None, D("100")) def test_target_without_members_is_violation(rules: Rules) -> None: @@ -1768,6 +1878,13 @@ def test_target_without_members_is_violation(rules: Rules) -> None: checks = failed(lines, BAL, FormVariant.FULL, rules) assert [f.relation for f in checks.relations_failed] == ["1600 = 1700"] assert checks.relations_failed[0].codes == ("1600", "1700") + assert (checks.relations_failed[0].expected, checks.relations_failed[0].actual) == (D("100"), None) + + +def test_zero_target_with_empty_members_is_violation(rules: Rules) -> None: + lines = [line("1600", "0"), line("1110", "0"), line("1100", "0")] + hard = failed(lines, BAL, FormVariant.FULL, rules).relations_failed + assert [(f.relation, f.expected, f.actual) for f in hard] == [("1600 = 1700", D("0"), None)] def test_duplicate_code_is_summed(rules: Rules) -> None: @@ -1845,6 +1962,14 @@ git -C "$R" commit -m "feat: шаг 7 — посчитанные соотнош неуверенным кодом, не подтверждённым структурой (их `needs_review = true`) - `confirm_codes(lines: Sequence[EvalLine], document_type: DocumentType, variant: FormVariant | None, effective: date | None, rules: Rules) -> StructureResult` + - `choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, + rules: Rules, *, variant: FormVariant | None = None) -> Edition | None` — **публичная**: шаги 2–3 и 6 алгоритма + ниже без подтверждения кодов. `codes` — коды строк `ok` по порядку строк как пары `(код, уверенный)`; уверенный ⇔ + `confidence == 100` или код зафиксирован аналитиком; пятизначные коды и коды вне перечня в последовательность не + входят. `variant is None` → `form_variant(document_type, коды)` (у `BALANCE`/`INCOME` — всегда не `None`). + `DISCLOSURE` → `edition_by_date(effective)`. `confirm_codes` и `recompute` берут редакцию **этой** функцией (с + `variant` документа), поэтому ядро, зовущее её при приёме результата, получает ту же редакцию, что пересчёт после + правки (P1-25) Алгоритм (спека «Шаг 8 — подтверждение неуверенного кода структурой»; сигнатура его не определяет): @@ -1871,7 +1996,7 @@ from datetime import date from arm_statements.evaluation import EvalLine from arm_statements.loader import Rules from arm_statements.model import DocumentType, Edition, FormVariant, LineStatus -from arm_statements.structure import confirm_codes +from arm_statements.structure import choose_edition, confirm_codes BAL = DocumentType.BALANCE Q3_2025 = date(2025, 9, 30) @@ -1921,6 +2046,24 @@ def test_tie_resolved_by_date(rules: Rules) -> None: assert confirm_codes(lines, BAL, FormVariant.FULL, date(2024, 12, 31), rules).edition is Edition.RAS_66N +def test_choose_edition_public(rules: Rules) -> None: + blank_66n = [("1110", True), ("1120", False), ("1130", True)] + assert choose_edition(BAL, blank_66n, Q3_2025, rules) is Edition.RAS_66N + tie = [("1210", True), ("1230", True)] + assert choose_edition(BAL, tie, Q3_2025, rules) is Edition.FSBU4 + assert choose_edition(BAL, tie, date(2024, 12, 31), rules) is Edition.RAS_66N + assert choose_edition(BAL, tie, None, rules) is None + assert choose_edition(BAL, [], Q3_2025, rules) is Edition.FSBU4 + assert choose_edition(DocumentType.DISCLOSURE, [("5101", True)], date(2024, 12, 31), rules) is Edition.RAS_66N + + +def test_choose_edition_equals_confirm_codes(rules: Rules) -> None: + lines = seq(("1110", 100.0), ("1120", 60.0), ("1130", 100.0)) + pairs = [(ln.line_code or "", (ln.code_confidence or 0.0) >= 100.0) for ln in lines] + expected = confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules).edition + assert choose_edition(BAL, pairs, Q3_2025, rules, variant=FormVariant.FULL) is expected + + def test_confident_nonstandard_code_not_flagged(rules: Rules) -> None: assert unconfirmed(seq(("1230", 100.0), ("1231", 100.0), ("11101", 100.0)), rules) == set() @@ -2122,7 +2265,8 @@ git -C "$R" commit -m "feat: шаг 8 — needs_review по покрытию §7 - Test: `$R/arm_statements/tests/test_status.py` **Interfaces:** -- Consumes: `DocumentType`, `LineStatus`, `Severity`, `DocStatus`, `EnvelopeStatus`, `StatementWarning`. +- Consumes: `DocumentType`, `LineStatus`, `Severity`, `DocStatus`, `EnvelopeStatus`, `StatementWarning`, + `StatementDocument`, `PeriodKind`. - Produces (`arm_statements/status.py`): - `StatusLine(line_status: LineStatus, needs_review: bool)`; `StatusValue(line_status: LineStatus, needs_review: bool)` - `StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool, @@ -2135,16 +2279,34 @@ git -C "$R" commit -m "feat: шаг 8 — needs_review по покрытию §7 - `envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus` — документов нет → `ERROR`; все `DONE` и нет предупреждений файла уровня `error` → `DONE`; иначе `NEEDS_CHECK` + - `status_input(document: StatementDocument) -> StatusInput` — **публичная**: вход функции статуса из документа + шагов 2–8 (статус документа — функция самого документа): `layout_found = document.lines != ()` (строк нет ⇔ столбец + кодов не найден ни у одного сегмента, задача 17 п. 2); `has_organization_or_inn` — `header.organization_name.value` + или `header.inn.value` не `None`; `period_known` — `header.report_date.value` не `None` или есть колонка с + `period_kind is ABSOLUTE` и `period_date` не `None`; `needs_review_fields = header.needs_review_fields`; `lines` и + `values` — `(line_status, needs_review)` строк и их значений; `hard_failures = len(checks.relations_failed)` (`checks + is None` → 0); `accepted = False`; `warning_severities` — уровни `document.warnings`. Конвейер (задача 17) считает + `status` как `document_status(status_input(<документ>))`; ядро — тем же правилом по документу контракта + (`status_input_from_contract`, задача 19) - [ ] **Step 1: Write the failing test** ```python from dataclasses import replace +from datetime import date +from decimal import Decimal as D +from arm_statements.header import parse_header from arm_statements.issues import make_warning from arm_statements.loader import Rules -from arm_statements.model import DocStatus, DocumentType, EnvelopeStatus, LineStatus, Severity -from arm_statements.status import StatusInput, StatusLine, StatusValue, document_status, envelope_status +from arm_statements.model import ( + DocStatus, DocumentType, EnvelopeStatus, FormVariant, LineStatus, PeriodKind, Severity, StatementChecks, + StatementColumn, StatementDocument, StatementLine, StatementValue, +) +from arm_statements.tests.builders import hdr +from arm_statements.status import ( + StatusInput, StatusLine, StatusValue, document_status, envelope_status, status_input, +) OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE DONE, CHECK = DocStatus.DONE, DocStatus.NEEDS_CHECK @@ -2192,6 +2354,24 @@ def test_envelope(rules: Rules) -> None: lost = make_warning("TABLE_LOST", "таблица без колонки кодов", rules, locator="file:a.json;table:1") assert envelope_status([DONE], [lost]) is EnvelopeStatus.NEEDS_CHECK assert envelope_status([], []) is EnvelopeStatus.ERROR + + +def test_status_input_from_document(rules: Rules) -> None: + head = parse_header(hdr(organization_name="ООО «Пример»", units="тыс. руб."), rules) + column = StatementColumn(2, "На 30.09.2025", 100.0, date(2025, 9, 30), PeriodKind.ABSOLUTE) + value = StatementValue(2, D("5"), None, "5", 60.0, True, needs_review=True) + detail = StatementLine(1, "row:1", DETAIL, None, None, None, "в т. ч.", 100.0, "row:0", False, (value,)) + line = StatementLine(0, "row:0", OK, "1210", "1210", 100.0, "Запасы", 100.0, None, False, + (replace(value, needs_review=False),)) + doc = StatementDocument(DocumentType.BALANCE, FormVariant.FULL, "file:t.json;table:0", head, (column,), + (line, detail), StatementChecks((), ()), DocStatus.DONE, ()) + data = status_input(doc) + assert (data.layout_found, data.has_organization_or_inn, data.period_known, data.hard_failures) == ( + True, True, True, 0) + assert data.values == (StatusValue(OK, False), StatusValue(DETAIL, True)) + assert document_status(data) is DONE + empty = replace(doc, columns=(), lines=(), checks=None) + assert (status_input(empty).layout_found, status_input(empty).period_known) == (False, False) ``` - [ ] **Step 2: Run test to verify it fails** @@ -2230,7 +2410,10 @@ git -C "$R" commit -m "feat: общая функция статуса докум исключения не перехватывает (хост ловит их на кандидате и пишет `TABLE_PARSE_ERROR`) Порядок `normalize_candidate` (спека «Шаг 6 — тип формы, вариант и документ», «Шаг 8»): -1. `header = parse_header(candidate.header)`; `layout = find_layout(candidate, header)`. +1. `header = replace(parse_header(candidate.header), form_name=)`, где `<title>` — `candidate.evidence.title.text` + как есть, если `title` есть и текст непуст после `strip()`, иначе `None` (заголовок формы не теряется при `normalize` + — документов меньше, чем кандидатов, и хосту не нужно сопоставлять документ с кандидатом); `ref` ячеек шапки и + `evidence` в выход не переносятся (у `HeaderField` нет `ref`); `layout = find_layout(candidate, header)`. 2. Нет столбца кодов (`not layout.code_found`): `type_hint` есть → документ без строк (`columns=()`, `lines=()`, `checks=None`, `form_variant=None` у `DISCLOSURE`, иначе `FULL`… — см. P1-19), `DISCLOSURE` → `DONE` без предупреждений шапки, иначе `CODE_COLUMN_NOT_FOUND` и статус по функции статуса (`layout_found=False`); `type_hint` @@ -2244,15 +2427,15 @@ git -C "$R" commit -m "feat: общая функция статуса докум `str(column_index)`, `fixed=False`); `evaluate(..., effective=layout.dates.effective)`; `checks_from`; `review_header`; значения и строки получают `needs_review` (`evaluate(..., accepted=False)`); документ: `form_variant` из `Evaluation`, `checks` (у документа со строками — всегда, даже пустые списки), предупреждения в порядке: адаптера, раскладки, - строк (`ROW_SPLIT`, `VALUE_OUT_OF_RANGE`), единиц, `TYPE_CONFLICT`, шапки; `status = document_status(...)` с - `layout_found=True`, `period_known = report_date ∨ latest_absolute`, `accepted=False`. + строк (`ROW_SPLIT`, `VALUE_OUT_OF_RANGE`), единиц, `TYPE_CONFLICT`, шапки; `status = + document_status(status_input(<документ>))` (задача 16) — у документов без строк (п. 2, п. 4) так же. - [ ] **Step 1: Write the failing test** ```python from decimal import Decimal as D -from arm_statements import normalize, normalize_candidate +from arm_statements import document_status, normalize, normalize_candidate, status_input from arm_statements.loader import Rules from arm_statements.model import DocStatus, DocumentType, FormVariant, Severity from arm_statements.tests.builders import excel, hdr, ocr @@ -2268,9 +2451,12 @@ def bal_rows() -> list[list[str]]: def test_clean_ocr_balance_done(rules: Rules) -> None: - result = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules) + cand = ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс") + result = normalize_candidate(cand, rules) doc = result.document assert doc is not None and result.file_warnings == () + assert doc.header.form_name == "Бухгалтерский баланс" + assert document_status(status_input(doc)) is doc.status assert (doc.document_type, doc.form_variant, doc.status) == (DocumentType.BALANCE, FormVariant.FULL, DocStatus.DONE) assert doc.checks is not None and doc.checks.relations_failed == () assert doc.header.inn.value == "7700000000" @@ -2312,6 +2498,7 @@ def test_hint_without_code_column(rules: Rules) -> None: assert doc.status is DocStatus.NEEDS_CHECK and "CODE_COLUMN_NOT_FOUND" in [w.code for w in doc.warnings] disc = normalize_candidate(ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE), rules).document assert disc is not None and (disc.status, disc.form_variant, disc.lines) == (DocStatus.DONE, None, ()) + assert disc.header.form_name is None def test_disclosure_by_codes(rules: Rules) -> None: @@ -2400,8 +2587,9 @@ accepted=accepted)`; `checks = checks_from(outcomes, {c.key: c.column_index})`; `detail_parents` — у каждой строки `DETAIL`: ближайшая выше по `row_index` строка `OK` с `row_index is not None`, у строки `DETAIL` без `row_index` — `None`; статус — `document_status` с `layout_found = any(row_index is not None)`, `hard_failures` из `Evaluation`, остальное из -`StoredForm`. `line_status`, суммы и коды не меняются. `DISCLOSURE` — `checks` пустые, `form_variant = None`, -`edition = edition_by_date(effective_date)`, `DONE`. +`StoredForm`. `line_status`, суммы и коды не меняются. `edition` — из `Evaluation` (то есть `choose_edition` по строкам +`ok` с `row_index` с `variant` документа; уверенный код — `line_code_confidence == 100` или `code_fixed`). +`DISCLOSURE` — `checks` пустые, `form_variant = None`, `edition = edition_by_date(effective_date)`, `DONE`. - [ ] **Step 1: Write the failing test** @@ -2410,7 +2598,7 @@ from dataclasses import replace from datetime import date from decimal import Decimal as D -from arm_statements import StoredColumn, StoredForm, StoredLine, StoredValue, recompute +from arm_statements import StoredColumn, StoredForm, StoredLine, StoredValue, choose_edition, recompute from arm_statements.loader import Rules from arm_statements.model import DocStatus, DocumentType, Edition, FormVariant, LineStatus, PeriodKind @@ -2461,6 +2649,13 @@ def test_code_change_switches_variant(rules: Rules) -> None: assert result.status is DocStatus.NEEDS_CHECK +def test_edition_equals_choose_edition(rules: Rules) -> None: + stored = form(sl("a", 0, "1110", "10"), sl("b", 1, "1120", "5", conf=60.0), sl("c", 2, "1130", "5")) + pairs = [("1110", True), ("1120", False), ("1130", True)] + expected = choose_edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), rules) + assert recompute(stored, rules).edition is expected is Edition.RAS_66N + + def test_accepted_and_empty_edit_line(rules: Rules) -> None: broken = form(sl("a", 0, "1600", "100"), sl("b", 1, "1700", "90"), sl("e", None, "1220", None)) assert recompute(broken, rules).status is DocStatus.NEEDS_CHECK @@ -2489,7 +2684,156 @@ git -C "$R" commit -m "feat: точка входа пересчёта формы --- -### Task 19: Golden на обезличенных входах (Review Focus), README, итоговая проверка +### Task 19: Перевод выхода в форму контракта v2.0 и вход статуса из документа контракта + +**Files:** +- Create: `$R/arm_statements/contract.py` +- Modify: `$R/arm_statements/__init__.py` (экспорт) +- Test: `$R/arm_statements/tests/test_contract.py` + +**Interfaces:** +- Consumes: `StatementDocument`, `StatementWarning`, `HEADER_FIELDS`, `StatusInput`, `StatusLine`, `StatusValue`, + `status_input` (задача 16), `DocumentType`, `LineStatus`, `Severity`, `PeriodKind`. +- Produces (`arm_statements/contract.py`; один перевод на оба сервиса — `contract_map` / `statements/contract.py` хостов + не пишутся, P1-26): + - `warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any]` — `{code, severity, + message}` (значения — строки) и только непустые необязательные ключи: `line_refs` (список, если не пуст), + `column_index` (если не `None`), `field` (если не `None`); `locator` — только при `file_level=True` и не `None` + (`fileWarning`; у предупреждения документа место — `locator` документа, ключа нет). Схема v2.0 не допускает `null` + у `column_index` и `field` — поэтому ключи опускаются, а не пишутся `null` + - `to_contract(document: StatementDocument) -> dict[str, Any]` — `statementDocument` схемы v2.0 как json-совместимый + `dict` (хост валидирует его своей моделью или схемой): `document_type`, `form_variant` (`null` у `DISCLOSURE`), + `locator`, `status`, `warnings` (`warning_to_contract`, без `locator`); `header` — плоский: `form_name`, по каждому + полю `HEADER_FIELDS` три ключа `<поле>` = `value` (дата — ISO), `<поле>_text` = `text`, `<поле>_confidence` = + `confidence` (все 30 ключей, `null` где нет), `needs_review_fields` (список); `columns[] {column_index, label, + label_confidence, period_date, period_kind}`; `lines[] {row_index, ref, line_status, line_code, line_code_text, + line_code_confidence, line_name, line_name_confidence, parent_ref, needs_review, values[] {column_index, amount, + string_value, text, confidence, is_recognized, needs_review}}`; `checks` — только если не `None`: + `{relations_failed[], soft_relations_failed[]}` из `{column_index, relation, codes, expected, actual}` (`column_key` + отбрасывается; `column_index is None` — `ValueError`: у сервиса номер колонки есть всегда). Деньги — строка + `format(x, "f")` или `null`; перечисления — значения; уверенности — `float`/`null`. `ref` ячеек шапки и `evidence` + в выходе нет (их нет в `StatementDocument`) + - `status_input_from_contract(document: Mapping[str, Any]) -> StatusInput` — **для ядра**: то же правило, что + `status_input` (задача 16), по `statementDocument` контракта v2.0 (`dict` после разбора json или `model_dump(mode="json")` + модели хоста; отсутствующий необязательный ключ и `null` — одно и то же): `layout_found = len(lines) > 0`; + `has_organization_or_inn` — `header.organization_name` или `header.inn` непусты; `period_known` — + `header.report_date` не `null` или есть колонка с `period_kind == "absolute"` и непустым `period_date`; + `needs_review_fields`; строки и значения — `(LineStatus(line_status), needs_review)`; `hard_failures = + len(checks.relations_failed)` (нет `checks` — 0); `accepted = False`; `warning_severities` — `Severity(w.severity)`. + Схему не проверяет: битый документ — `KeyError` / `ValueError` (ядро валидирует результат до вызова) + +Свойство, которое закрепляют тесты: для любого документа пакета `status_input_from_contract(to_contract(doc)) == +status_input(doc)` и `document_status(status_input(doc)) is doc.status` — ядро, проверяя `document.status` результата, +получает ровно то, что посчитал сервис. + +- [ ] **Step 1: Write the failing test** + +```python +import json +from dataclasses import replace +from decimal import Decimal as D + +import pytest + +from arm_statements import ( + DocStatus, DocumentType, document_status, make_warning, normalize_candidate, status_input, + status_input_from_contract, to_contract, warning_to_contract, +) +from arm_statements.loader import Rules +from arm_statements.model import FailedRelation, StatementChecks +from arm_statements.tests.builders import hdr, ocr + +HEAD = ["Наименование показателя", "Код", "На 30.09.2025"] +FULL_HEADER = hdr(organization_name="ООО «Пример»", inn=("7700000000", 80.0), report_date="30.09.2025", + units="тыс. руб.") +HEADER_KEYS = {"form_name", "needs_review_fields"} | { + f"{name}{suffix}" + for name in ("organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address") + for suffix in ("", "_text", "_confidence") +} + + +def balance(rules: Rules, *rows: list[str]): + cand = ocr([HEAD, *rows], header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс") + doc = normalize_candidate(cand, rules).document + assert doc is not None + return doc + + +def test_document_in_contract_form(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"]) + data = json.loads(json.dumps(to_contract(doc), ensure_ascii=False)) + assert set(data["header"]) == HEADER_KEYS + assert (data["header"]["form_name"], data["header"]["inn"], data["header"]["inn_confidence"]) == ( + "Бухгалтерский баланс", "7700000000", 80.0) + assert data["header"]["report_date"] == "2025-09-30" and data["header"]["okei"] is None + assert data["lines"][0]["values"][0]["amount"] == "60.00" + assert {ln["line_status"] for ln in data["lines"]} >= {"ok"} + assert "column_key" not in json.dumps(data) and "row:" not in json.dumps(data["header"]) + assert "locator" not in json.dumps(data["warnings"]) + assert set(data["checks"]) == {"relations_failed", "soft_relations_failed"} + + +def test_empty_target_is_null_in_contract(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["Итого I", "1100", "40"]) + failed = to_contract(doc)["checks"]["relations_failed"] + target = next(f for f in failed if f["relation"] == "1600 = 1100 + 1200") + assert (target["expected"], target["actual"], target["column_index"]) == (None, "100.00", 2) + + +def test_no_lines_no_checks_key(rules: Rules) -> None: + doc = normalize_candidate(ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER), + rules).document + assert doc is not None and "checks" not in to_contract(doc) and to_contract(doc)["lines"] == [] + + +def test_relation_without_column_index_is_error(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"]) + bad = FailedRelation(None, "c1", "1600 = 1700", ("1600", "1700"), D("1"), None) + with pytest.raises(ValueError, match="column_index"): + to_contract(replace(doc, checks=StatementChecks((bad,), ()))) + + +def test_warning_keys(rules: Rules) -> None: + w = make_warning("ROW_SPLIT", "разлеплено", rules, line_refs=("row:3#1", "row:3#2"), locator="file:a.json") + assert warning_to_contract(w) == {"code": "ROW_SPLIT", "severity": "info", "message": "разлеплено", + "line_refs": ["row:3#1", "row:3#2"]} + lost = make_warning("TABLE_LOST", "таблица", rules, locator="file:a.json;table:1") + assert warning_to_contract(lost, file_level=True) == {"code": "TABLE_LOST", "severity": "error", + "message": "таблица", "locator": "file:a.json;table:1"} + + +def test_status_input_roundtrip(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"]) + data = status_input_from_contract(json.loads(json.dumps(to_contract(doc), ensure_ascii=False))) + assert data == status_input(doc) + assert document_status(data) is doc.status is DocStatus.NEEDS_CHECK +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_contract.py -v` +Expected: FAIL — `ImportError: cannot import name 'to_contract'` + +- [ ] **Step 3: Implement `contract.py`** по Interfaces; экспорт `to_contract`, `warning_to_contract`, + `status_input_from_contract`, `status_input` в `__init__.py`. Ключи `header` строятся циклом по `HEADER_FIELDS` (новое + поле шапки — одна правка в `model.py`). + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: перевод документа и предупреждений в форму контракта v2.0, вход статуса из документа контракта" +``` + +--- + +### Task 20: Golden на обезличенных входах (Review Focus), README, итоговая проверка **Files:** - Create: `$R/arm_statements/tests/golden/{aep3_balance,aep3_income,glued_balance,kody_title_balance,npz_income}.candidate.json` @@ -2499,7 +2843,8 @@ git -C "$R" commit -m "feat: точка входа пересчёта формы - Modify: `$R/arm_statements/__init__.py` (окончательный `__all__`) **Interfaces:** -- Consumes: `normalize_candidate`, `to_jsonable`, `candidate_from_dict`, `builders`. +- Consumes: `normalize_candidate`, `to_jsonable`, `candidate_from_dict`, `builders`, `to_contract`, + `status_input`, `status_input_from_contract`, `document_status`. - Produces: ничего нового для других задач. Кандидаты собираются один раз скриптом в теле теста-генератора (`builders` → `to_jsonable` → файл) и дальше лежат @@ -2518,7 +2863,9 @@ from pathlib import Path import pytest -from arm_statements import normalize_candidate +from arm_statements import ( + document_status, normalize_candidate, status_input, status_input_from_contract, to_contract, +) from arm_statements.loader import Rules from arm_statements.model import DocStatus, LineStatus, StatementDocument from arm_statements.serialize import candidate_from_dict, to_jsonable @@ -2545,6 +2892,14 @@ def test_golden_snapshot(name: str, rules: Rules) -> None: assert actual == json.loads(target.read_text(encoding="utf-8")) +@pytest.mark.parametrize("name", NAMES) +def test_status_is_function_of_contract_document(name: str, rules: Rules) -> None: + doc = run(name, rules) + contract = json.loads(json.dumps(to_contract(doc), ensure_ascii=False)) + assert status_input_from_contract(contract) == status_input(doc) + assert document_status(status_input(doc)) is doc.status + + def test_aep3_junk_codes(rules: Rules) -> None: # Review Focus 1 doc = run("aep3_balance", rules) by_text = {ln.line_code_text: ln for ln in doc.lines} @@ -2635,6 +2990,8 @@ PUBLIC = { "fix_lookalikes", "make_warning", "StatusInput", "StatusLine", "StatusValue", "document_status", "envelope_status", "StoredForm", "StoredColumn", "StoredLine", "StoredValue", "StoredWarning", "RecomputeResult", "recompute", "to_jsonable", "candidate_from_dict", "statements_version", + "WarningCode", "choose_edition", "status_input", "status_input_from_contract", "to_contract", "warning_to_contract", + "code_column", "amount_columns", "normalize_label", "normalize_caption", "is_continuation", } @@ -2644,7 +3001,8 @@ def test_public_api() -> None: ``` `README.md`: «Публичный API» (копия раздела ниже), «Как менять словари» (PR сюда; перечень кодов — только из docs, -байтовой копией; новый код предупреждения — `statement_warnings.yaml` + таблица §11.9 docs), «golden-update». +байтовой копией; новый код предупреждения — `statement_warnings.yaml` + член `WarningCode` + таблица §11.9 docs и +таблица спеки «Шаг 8 — предупреждения»), «golden-update». - [ ] **Step 5: Final verification** @@ -2664,7 +3022,9 @@ git -C "$R" commit -m "test: golden на обезличенных входах a ## Публичный API пакета Импорт — `from arm_statements import …`. Всё, что не перечислено, — внутреннее (может меняться без координации с -хостами). Все dataclass-ы — `frozen=True, slots=True`; последовательности — `tuple`. +хостами). Все dataclass-ы — `frozen=True, slots=True`; последовательности — `tuple`. Раздел сведён с запросами планов +3, 4, 5 (18.10.2026, P1-21…P1-28): хосты не повторяют правил пакета у себя — ни столбца кодов, ни нормализации меток, ни +перевода в контракт. ### Перечисления (`StrEnum`, значение — строка контракта) @@ -2680,6 +3040,7 @@ git -C "$R" commit -m "test: golden на обезличенных входах a | `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`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») | ### Вход шага 1 (строит адаптер хоста) @@ -2688,8 +3049,11 @@ 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 + # 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, ...] = (), @@ -2709,8 +3073,9 @@ 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, needs_review_fields: tuple[str, ...] = ()) - # контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence + 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, @@ -2720,45 +3085,65 @@ StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str 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, actual: Decimal) - # сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key + 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) + # 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) normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult - # по порядку; исключение кандидата не перехватывается — хост ловит его и пишет TABLE_PARSE_ERROR -document_status(data: StatusInput) -> DocStatus -envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus - # ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост + # по порядку; исключение кандидата не перехватывается — хост, которому нужна изоляция кандидатов + # (TABLE_PARSE_ERROR), зовёт normalize_candidate в цикле recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult -parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader +parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader # form_name = None (ставит конвейер) type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None -continuation_type(text: str | None, rules: Rules) -> DocumentType | None # маркер «Форма 0710001 с.2» +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 # лист без маркера и шапки + 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 - # уровень — из словаря statement_warnings.yaml; хосты создают JSON_FAILED, TABLE_PARSE_ERROR, FILE_TYPE_MISMATCH, - # XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED только через неё -to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение + # 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 при ошибке словаря @@ -2768,6 +3153,21 @@ default_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 @@ -2777,6 +3177,12 @@ StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or 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) выбирает хост ``` ### Пересчёт по сохранённым данным (ядро) @@ -2798,15 +3204,40 @@ StoredForm(document_type: DocumentType, effective_date: date | None, has_organiz 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 + # value_needs_review: ключ (StoredLine.key, StoredValue.column_key); detail_parents — только строки DETAIL; + # checks.*.expected / actual — Decimal | None, как у сервиса ``` ### Словари (`Rules`) — что нужно хостам -`Rules.headers.labels` / `auditor` / `legal_form` / `form_titles` / `okud` / `continuation` — метки шапки и заголовки -для адаптера Excel; `Rules.code_orders.sequence(edition, form, variant)` и `Rules.code_orders.editions` — перечень -кодов редакции и порядок редакций (ядру — порядок и состав строк формы); `Rules.checks.tolerance` — допуск ±1 (ядру — -для межформенной проверки); `Rules.lookalikes` — буквы-двойники; `Rules.warnings` — уровни предупреждений. +Хосты читают словари только через эти атрибуты; прочие поля `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` | --- @@ -2868,6 +3299,55 @@ RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, check `accepted` всегда ложно. Цена: после акцепта ячейки несошедшегося соотношения не подсвечиваются (нарушение видно в `checks`). +- **P1-21.** `FailedRelation.expected` / `actual` (и `RelationOutcome`) — `Decimal | None`: `None` — цель или все члены + пусты; такое посчитанное соотношение — всегда нарушение (допуск не применяется). Контракт v2.0 так и задаёт + (`expected` — `money`, «`null` — цели нет»; `actual` — `money`), агрегат ядра тоже не превращает пустое в 0 (план 3, + Review Focus 2). Цена: хосты и ядро обрабатывают `None` в тексте нарушения («пусто»); при ошибке — `0` вместо пустоты + прятал бы «пустую цель» в допуск ±1 (`1600 = 0` при пустом `1700` считалось бы сошедшимся). +- **P1-22.** Две публичные нормализации текста: `normalize_label` (дословно `normalize_text` tabular-parser — пунктуация + сохраняется; под неё записаны перенесённые из tabular-parser метки шапки, заголовки, ОКУД, маркер продолжения) и + `normalize_caption` (подписи колонок, единицы, относительные периоды). Таблица «ключ словаря → нормализация» — в + разделе API; `type_hint` и `is_continuation` пакета пользуются `normalize_label`. Цена: новый синоним пишется под + нормализацию своего ключа; ошибка — синоним молча не срабатывает (ловят тесты пакета на синтетике и покрытие словарей + в tabular-parser, O-8 плана 4). +- **P1-23.** Новый код `XLS_NO_CODEPAGE` (`info`, файл): книга xls без кодовой страницы — заметка читателя, относящаяся ко + всей книге (и к формам, и к ОСВ). Строки добавлены в таблицу спеки «Шаг 8 — предупреждения» и §11.9 `05-domain-rules.md` + (правило спеки: «новый код вводится словарём пакета и таблицей §11»). Цена: tabular-parser выдаёт его в + `metrics.warnings` вместо `OSV_XLS_NO_CODEPAGE` в каждом документе ОСВ (план 4, P4-13); экран показывает его в карточке + файла, а не у листа ОСВ. +- **P1-24.** `resolve_relative(ABSOLUTE | NONE, …)` → `None`, а не исключение: у таких колонок дата не вычисляется, а ядро + перебирает колонки формы одним циклом. Цена: вызов с неверным видом не заметен (ядро зовёт функцию только для + `relative:*`, план 3 B2). +- **P1-25.** `choose_edition(document_type, codes: Sequence[tuple[str, bool]], effective, rules, *, variant=None)` вместо + запрошенной `choose_edition(effective, codes, rules)`: перечень кодов зависит от формы и варианта, а «лучшая + подпоследовательность» по спеке сравнивает сначала число **уверенных** кодов — без типа и признака уверенности + результат расходился бы с `recompute`. `confirm_codes` и `recompute` берут редакцию этой же функцией. Цена: ядро + передаёт пары `(код, уверенный)`, собранные из строк результата (`line_code_confidence == 100`). +- **P1-26.** Перевод выхода в форму контракта v2.0 (`to_contract`, `warning_to_contract`) — в пакете, а не в каждом + сервисе: одно уплощение шапки, одно правило «необязательные ключи без `null`», один отброс `column_key`. Пакет теперь + знает форму `statementDocument` v2.0. Цена: новый MAJOR контракта меняет и пакет (синхронизация копий — план 2), а + модель контракта хоста валидирует `dict` пакета — расхождение видно тестом схемы хоста, а не пакета. +- **P1-27.** Статус документа — функция документа: `status_input(StatementDocument)` (конвейер считает им `status`) и + `status_input_from_contract(dict)` для ядра (страховка `document.status` результата 2.0); свойство + `status_input_from_contract(to_contract(d)) == status_input(d)` закреплено тестами задач 19 и 20. `layout_found` + выводится из «строки есть» (строк нет ⇔ столбец кодов не найден, задача 17 п. 2). Цена: если в будущем появится + документ со столбцом кодов, но без строк, функции нужен явный признак раскладки. +- **P1-28.** `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source)` — публичные + обёртки шага 2 над сеткой `Cell` (адаптер Excel строит её из листа); минимум ослабленного поиска и слабого продолжения + — данные словаря (`min_codes_continuation: 1`, был литерал «минимум один» в коде шага 2 и `min_codes_continuation` + tabular-parser). `relaxed` задаётся типом формы, а не внутренним `CodeSection`. Цена: адаптеру нужна сетка `Cell` листа, + чтобы спросить столбец кодов (у tabular-parser она уже есть — `grid_rows` плана 4). +- **P1-29.** `form_name` — поле `StatementHeader` (в контракте он в `header`), а не `StatementDocument`; ставит конвейер из + `evidence.title.text` как есть, `parse_header` оставляет `None`. Цена: хост, собирающий шапку `parse_header` сам (без + конвейера), заголовка не получит — таких вызовов в планах нет. +- **P1-30.** Коды предупреждений — `WarningCode` (`StrEnum`) в `issues.py`; загрузчик требует равенства множества кодов + словаря и перечисления; `make_warning` с неизвестным кодом — `KeyError`. Цена: новый код — две правки в пакете + (словарь + член перечисления) и строка §11.9 docs; опечатку в коде хоста ловит mypy (член перечисления), а строковый + литерал — только `KeyError` при вызове. +- **P1-31.** `Rules.code_orders.sequence(…, DISCLOSURE, …)` → `()`, а не `KeyError`: у пояснений перечня нет, и + структурная проверка, и проверки ядра по редакции к ним не применяются. Цена: вызов для пояснений молча даёт пустой + перечень. + ## Открытые вопросы (O-n) - **O-1.** Спека: копия лежит «вместе с … конфигом линтеров»; design Р-1: «конфиг пакета подключается к `pytest` и @@ -2880,15 +3360,7 @@ RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, check `pytest.ini` хоста задаёт `env` (`APP_ENV=test`) — тестам пакета он не нужен и не мешает; подтверждение — в плане 2. - **O-4.** Dockerfile хостов должен копировать `api/arm_statements/rules/*.yaml` в образ (словари читаются с диска) — в план 2 или планы сервисов. -- **O-5.** План 3 (arm-core, `2026-10-18-plan-3-arm-core.md`, таблица «Ожидаемые имена пакета») написан параллельно - и ждёт сверки с этим разделом (его O-8). Расхождения, которые правит адаптер `statements_engine.py` плана 3: - `recompute_stored_form` / `StoredFormResult` → `recompute` / `RecomputeResult`; `load_code_orders()`, - `CodeOrders.order`, `.editions` → `default_rules().code_orders.sequence(...)`, `.editions`; `relative_period_date` → - `resolve_relative`; `load_check_rules().tolerance` → `default_rules().checks.tolerance`; уверенности — - `float | None`, а не `Decimal`; `StoredValue.is_recognized` — `bool` (не `bool | None`); `StoredColumn.column_index` - — `int | None`; `period_kind`, `line_status`, `severity`, `document_type`, `edition`, `form_variant` — `StrEnum` - пакета (подкласс `str`, значения — строки контракта); `edition` результата — `Edition | None`; - `FailedRelation` — `column_index: int | None` плюс `column_key: str`, `expected` и `actual` — `Decimal` (не - `None`); `StatusInput` — `lines: tuple[StatusLine, ...]`, `values: tuple[StatusValue, ...]`, `hard_failures` - вместо `relations_failed`, дополнительно `needs_review_fields`. Решить, какая сторона меняется (по умолчанию — - адаптер плана 3, как он сам предусматривает). +- **O-5.** Закрыт 18.10.2026: план 3 сверен с разделом «Публичный API пакета» (его «Что ядро вызывает у пакета» + использует `recompute`, `RecomputeResult`, `choose_edition`, `status_input_from_contract`, `resolve_relative`, + `Rules.code_orders.*`, `Rules.checks.tolerance`; уверенности — `float | None`, перечисления — `StrEnum` пакета, + `FailedRelation.expected` / `actual` — `Decimal | None`, P1-21). diff --git a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md index 044eb6e..42c54c6 100644 --- a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md +++ b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md @@ -776,6 +776,7 @@ report_date`). У бланка только с относительными по | `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок | | `UNITS_NOT_FOUND` | info | единицы не найдены | | `FILE_TYPE_MISMATCH` | info (файл) | формат файла не совпал с `file_type` запроса | +| `XLS_NO_CODEPAGE` | info (файл) | книга xls без кодовой страницы — текст мог прочитаться неверно | | `COLUMN_SKIPPED` | info | подпись из словаря исключений пропущена | | `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) | | `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны |