From 387e92e014d15b09b39841e12367b83a4cdfbb3e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 13:31:26 +0400 Subject: [PATCH 1/2] =?UTF-8?q?pylib=5Fai=5Farm=5Fstatements=200.1.5:=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=B9=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Пин pylib_ai_arm_statements==0.1.5 (0.1.4 — группа альтернатив 2400, AURORA-1864; 0.1.5 — split_by_form и редкий столбец, AURORA-1734/1735). - StatementPipeline режет кандидата split_by_form перед normalize_candidate: ББ и ОФР одним листом без заголовка ОФР — два документа (§11, спека statement-normalization «Шаг 2 — разрез кандидата по смене формы»). Тест на такой лист. - Golden трёх ОФР — fixtures #36 (уходит ложное 2400). - CLAUDE.md: версия библиотеки — по пину, конвейер с split_by_form. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- api/requirements.txt | 2 +- api/src/parser/statements/pipeline.py | 30 +++++++------- .../unit/parser/statements/test_pipeline.py | 40 +++++++++++++++++-- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfba77e..c6ecccb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( новый формат = новая фикстура + синоним, не новый `if`. ## Правила репозитория -- `arm_statements` — библиотека `pylib_ai_arm_statements==0.1.1`; установка и обновление — `make install` +- `arm_statements` — библиотека `pylib_ai_arm_statements` (версия — пин в `api/requirements.txt`); установка и обновление — `make install` (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`); код возврата 20 при расхождении. Словари форм — в пакете; `api/rules/statement_*.yaml` удалены планом 4 (#54). @@ -117,7 +117,7 @@ property, покрытие словарей); без каталога `../fixtur **Модули адаптера** (`api/src/parser/statements/`): `forms.py` (блоки форм и вид листа по словарям пакета), `cells.py` (ячейка Excel -> `arm_statements.Cell`), `header_cells.py` (шапка по меткам словаря), `adapter.py` (`plan_sheets`, `build_candidates`: листы -> кандидаты для пакета, продолжение присоединяется), `pipeline.py` (`StatementPipeline`: -кандидаты -> `normalize_candidate` -> `to_contract`), `number_format.py` (числовой формат ячейки, #42). +кандидаты -> `split_by_form` (разрез по смене формы, AURORA-1734) -> `normalize_candidate` -> `to_contract`), `number_format.py` (числовой формат ячейки, #42). `book.py` (`BookParser`) склеивает ОСВ и формы; `get_parser(settings)` создаёт его с `default_rules()` пакета. **Контракт 2.0:** `CONTRACT_VERSION = "2.0"`; у документов `status` и `warnings`; `checks` обязателен у BALANCE/INCOME со diff --git a/api/requirements.txt b/api/requirements.txt index 3674953..4ba7ab2 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -43,7 +43,7 @@ Pygments==2.20.0 PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 -pylib_ai_arm_statements==0.1.3 +pylib_ai_arm_statements==0.1.5 pyxlsb==1.0.10 PyYAML==6.0.3 rich==15.0.0 diff --git a/api/src/parser/statements/pipeline.py b/api/src/parser/statements/pipeline.py index aa6ac48..0583888 100644 --- a/api/src/parser/statements/pipeline.py +++ b/api/src/parser/statements/pipeline.py @@ -1,15 +1,16 @@ """Конвейер форм книги: кандидаты адаптера → шаги 2–8 пакета `arm_statements` → документы контракта 2.0. -Своего перевода в контракт здесь нет: документ формы — `to_contract` пакета, предупреждение файла — -`warning_to_contract(..., file_level=True)`. Изоляцию кандидатов друг от друга не делаем: исключение пакета — ошибка -книги (`RECOGNITION_FAILED` в `BookParser`). +Перед шагами 2–8 кандидат режется по смене формы (`split_by_form` пакета, AURORA-1734): ББ и ОФР одним листом без +своего заголовка — два документа. Своего перевода в контракт здесь нет: документ формы — `to_contract` пакета, +предупреждение файла — `warning_to_contract(..., file_level=True)`. Изоляцию кандидатов друг от друга не делаем: +исключение пакета — ошибка книги (`RECOGNITION_FAILED` в `BookParser`). """ from collections.abc import Sequence from dataclasses import dataclass from arm_statements import Rules as StatementRules -from arm_statements import StatementWarning, normalize_candidate, to_contract, warning_to_contract +from arm_statements import StatementWarning, normalize_candidate, split_by_form, to_contract, warning_to_contract from src.contract import FileWarning, StatementDocument from src.parser.deadline import Deadline @@ -37,16 +38,17 @@ class StatementPipeline: def parse(self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, xlsb: bool) -> StatementsOutcome: documents: list[StatementDocument] = [] file_warnings: list[FileWarning] = [] - for candidate in build_candidates(sheets, self._rules, deadline, xlsb=xlsb): - deadline.check(f"форма «{candidate.locator}»") - result = normalize_candidate(candidate, self._rules) - if result.document is not None: - documents.append(StatementDocument.model_validate(to_contract(result.document))) - else: - # Документа нет: предупреждения адаптера (формат xlsb, числовой формат, склейка) пакет не переносит — - # они остаются предупреждениями файла со своим locator - file_warnings.extend(_file_warning(w) for w in candidate.warnings) - file_warnings.extend(_file_warning(w) for w in result.file_warnings) + for source in build_candidates(sheets, self._rules, deadline, xlsb=xlsb): + for candidate in split_by_form(source, self._rules): + deadline.check(f"форма «{candidate.locator}»") + result = normalize_candidate(candidate, self._rules) + if result.document is not None: + documents.append(StatementDocument.model_validate(to_contract(result.document))) + else: + # Документа нет: предупреждения адаптера (формат xlsb, числовой формат, склейка) пакет не + # переносит — они остаются предупреждениями файла со своим locator + file_warnings.extend(_file_warning(w) for w in candidate.warnings) + file_warnings.extend(_file_warning(w) for w in result.file_warnings) return StatementsOutcome(tuple(documents), tuple(file_warnings)) diff --git a/api/tests/unit/parser/statements/test_pipeline.py b/api/tests/unit/parser/statements/test_pipeline.py index f5c257a..8fc953f 100644 --- a/api/tests/unit/parser/statements/test_pipeline.py +++ b/api/tests/unit/parser/statements/test_pipeline.py @@ -31,9 +31,9 @@ def statement_rules() -> StatementRules: return default_rules() -def _balance(name: str = "Баланс") -> Sheet: +def _balance_rows() -> list[list[object]]: """Шапка из восьми строк, подпись столбцов в 9-й, первая строка с кодом — 10-я (код в столбце C).""" - rows = [ + return [ at({1: "Бухгалтерский баланс"}), at({1: "на 31 марта 2025 г."}), at({1: "Организация", 2: "ООО Ромашка"}), @@ -45,7 +45,10 @@ def _balance(name: str = "Баланс") -> Sheet: at({1: "Наименование показателя", 3: "Код", 4: "На 31 марта 2025 г."}), *[at({1: title, 3: code, 4: amount}) for title, code, amount in BALANCE_LINES], ] - return sheet_from_rows(rows, name=name) + + +def _balance(name: str = "Баланс") -> Sheet: + return sheet_from_rows(_balance_rows(), name=name) def test_document_converts_to_contract(statement_rules: StatementRules) -> None: @@ -83,3 +86,34 @@ def test_xlsb_flag_adds_document_warning(statement_rules: StatementRules) -> Non outcome = StatementPipeline(statement_rules).parse([(_balance(), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=True) assert "XLSB_NO_FORMATS" in {w.code for w in outcome.documents[0].warnings} + + +INCOME_LINES = ( + ("Выручка", 2110, 300), + ("Себестоимость продаж", 2120, -200), + ("Валовая прибыль (убыток)", 2100, 100), + ("Прибыль (убыток) от продаж", 2200, 100), + ("Чистая прибыль (убыток)", 2400, 100), +) + + +def test_balance_and_income_on_one_sheet_split_into_two_documents(statement_rules: StatementRules) -> None: + """ББ и ОФР одним листом без заголовка ОФР: шапка таблицы ОФР режет кандидата (`split_by_form`, AURORA-1734).""" + rows = [ + *_balance_rows(), + at({1: "Наименование показателя", 3: "Код", 4: "За январь - март 2025 г."}), + *[at({1: title, 3: code, 4: amount}) for title, code, amount in INCOME_LINES], + ] + + outcome = StatementPipeline(statement_rules).parse( + [(sheet_from_rows(rows, name="Баланс"), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=False + ) + + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("BALANCE", "sheet:Баланс"), + ("INCOME", "sheet:Баланс;form:1"), + ] + balance, income = outcome.documents + assert {line.line_code for line in balance.lines} == {str(code) for _, code, _ in BALANCE_LINES} + assert {line.line_code for line in income.lines} == {str(code) for _, code, _ in INCOME_LINES} + assert [str(c.period_date) for c in income.columns] == ["2025-03-31"] -- 2.47.2 From d722e0b76c06035e7ba9d79f7e4ac3afc802d23b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 14:18:05 +0400 Subject: [PATCH 2/2] =?UTF-8?q?=D0=98=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F:=20=D1=83=20=D0=9D=D0=9F=D0=97=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=C2=AB2400=20=3D=202300=20=E2=88=92=202410=20+=202420=C2=BB=20(?= =?UTF-8?q?#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit С 0.1.4 соотношения 2400 — группа альтернатив (AURORA-1864): тест сквозного разбора форм проверял, что расхождение есть, теперь — что его нет (как в golden НПЗ, fixtures #36). Co-Authored-By: Claude Opus 5.5 --- api/tests/integration/test_roundtrip.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 8b170f5..b0cf924 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -362,10 +362,10 @@ async def test_real_statements_through_worker_give_done_with_two_documents( "1500 = 1510 + 1520 + 1530 + 1540 + 1550", "1410 = 14101 + 14102 + 14103 + 14104 + 14105", } - assert {item["relation"] for item in income["checks"]["soft_relations_failed"]} >= { - "2400 = 2300 - 2410 + 2420", - "2410 = 2411 + 2412", - } + income_soft = {item["relation"] for item in income["checks"]["soft_relations_failed"]} + assert "2410 = 2411 + 2412" in income_soft + # 2400 — группа альтернатив (0.1.4, AURORA-1864): одна из них в НПЗ сходится, ложного расхождения нет + assert "2400 = 2300 - 2410 + 2420" not in income_soft # лист с единственной строкой 2400 присоединён (O-9) assert "2400" in {line["line_code"] for line in income["lines"]} await core.assert_quiet(request) -- 2.47.2