From 9e694796ad35235c449cee97a6e053263412736f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:01:53 +0400 Subject: [PATCH] =?UTF-8?q?test:=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=B0=20=D0=BA=D0=BD=D0=B8=D0=B3=D0=B8,=20test=5Fp?= =?UTF-8?q?ipeline,=20=D1=84=D0=BB=D0=B0=D0=B3=20BookRead.no=5Foutline=20(?= =?UTF-8?q?fix=20round=201,=20#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/parser/book.py | 3 +- api/src/parser/readers/__init__.py | 6 +- .../unit/parser/statements/test_pipeline.py | 85 +++++++++++++++++++ api/tests/unit/parser/test_book_parser.py | 63 ++++++++++++-- 4 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 api/tests/unit/parser/statements/test_pipeline.py diff --git a/api/src/parser/book.py b/api/src/parser/book.py index 9e20d74..9899874 100644 --- a/api/src/parser/book.py +++ b/api/src/parser/book.py @@ -43,6 +43,7 @@ from src.parser.readers import BookRead, read_book_detected from src.parser.readers._common import one_line_reason from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR from src.parser.readers.xls import NO_CODEPAGE_WARNING +from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING from src.parser.rules import Rules from src.parser.statements.adapter import plan_sheets from src.parser.statements.pipeline import StatementPipeline @@ -64,7 +65,7 @@ def _as_package(warning: FileWarning) -> StatementWarning: def _reader_notes(read: BookRead) -> list[OsvNote]: """Заметки читателя для документов ОСВ (сейчас — xlsb без outline).""" - return [OsvNote(OSV_XLSB_NO_OUTLINE, message) for message in read.warnings] + return [OsvNote(OSV_XLSB_NO_OUTLINE, OUTLINE_UNAVAILABLE_WARNING)] if read.no_outline else [] def _sheet_position(locator: str | None, names: list[str]) -> int: diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py index bef9f36..89667a4 100644 --- a/api/src/parser/readers/__init__.py +++ b/api/src/parser/readers/__init__.py @@ -56,7 +56,8 @@ class BookRead(NamedTuple): `warnings` — заметки читателя для листов ОСВ (сейчас только `OUTLINE_UNAVAILABLE_WARNING` у xlsb); `detected` — формат по содержимому (а не заявленный `file_type`); `mismatch` — строка несовпадения «file_type=…, но содержимое - …: прочитано как …» либо `None`; `no_codepage` — xls без записи CODEPAGE (строки прочитаны как cp1251). + …: прочитано как …» либо `None`; `no_codepage` — xls без записи CODEPAGE (строки + прочитаны как cp1251); `no_outline` — xlsb (outline level недоступен). """ sheets: tuple[Sheet, ...] @@ -64,6 +65,7 @@ class BookRead(NamedTuple): detected: FileType mismatch: str | None = None no_codepage: bool = False + no_outline: bool = False def read_book( @@ -97,7 +99,7 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt if detected == "XLSB": warnings.append(OUTLINE_UNAVAILABLE_WARNING) no_codepage = detected == "XLS" and has_no_codepage(data) - return BookRead(sheets, warnings, detected, mismatch, no_codepage) + return BookRead(sheets, warnings, detected, mismatch, no_codepage, no_outline=detected == "XLSB") def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType: diff --git a/api/tests/unit/parser/statements/test_pipeline.py b/api/tests/unit/parser/statements/test_pipeline.py new file mode 100644 index 0000000..1ce7184 --- /dev/null +++ b/api/tests/unit/parser/statements/test_pipeline.py @@ -0,0 +1,85 @@ +"""Конвейер форм книги: кандидаты адаптера → пакет → документы и предупреждения файла контракта 2.0.""" + +import pytest +from gridbuilder import at, sheet_from_rows +from result_contract import assert_valid_result + +from arm_statements import Rules as StatementRules +from arm_statements import default_rules +from src.contract import FileWarning, Metrics, ResultStatus +from src.parser import ParseOutcome +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.grid import Sheet +from src.parser.statements.pipeline import StatementPipeline + +NO_DEADLINE = Deadline(10_000.0) +BALANCE_LINES = ( + ("Основные средства", 1150, 100), + ("Итого по разделу I", 1100, 100), + ("Запасы", 1210, 50), + ("Итого по разделу II", 1200, 50), + ("БАЛАНС", 1600, 150), + ("Нераспределенная прибыль", 1370, 150), + ("Итого по разделу III", 1300, 150), + ("БАЛАНС", 1700, 150), +) + + +@pytest.fixture(scope="module") +def statement_rules() -> StatementRules: + return default_rules() + + +def _balance(name: str = "Баланс") -> Sheet: + """Шапка из восьми строк, подпись столбцов в 9-й, первая строка с кодом — 10-я (код в столбце C).""" + rows = [ + at({1: "Бухгалтерский баланс"}), + at({1: "на 31 марта 2025 г."}), + at({1: "Организация", 2: "ООО Ромашка"}), + at({1: "ИНН", 2: "7700000000"}), + at({1: "Единица измерения", 2: "тыс. руб."}), + [], + [], + [], + 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 test_document_converts_to_contract(statement_rules: StatementRules) -> None: + outcome = StatementPipeline(statement_rules).parse([(_balance(), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=False) + + assert outcome.file_warnings == () + document = outcome.documents[0] + assert document.header.form_name == "Бухгалтерский баланс" + assert (document.header.inn, document.header.inn_text, document.header.inn_confidence) == ( + "7700000000", + "7700000000", + 100, + ) + assert document.lines[0].ref == "Баланс!C10" + assert document.status == "done" + assert_valid_result(ParseOutcome(status=ResultStatus.DONE, documents=outcome.documents, metrics=Metrics(rows=8))) + + +def test_file_warnings_are_contract_models(statement_rules: StatementRules) -> None: + """Лист без формы (нет заголовка и кодов) → документа нет, `SHEET_OTHER` как `FileWarning` с locator.""" + sheet = sheet_from_rows([at({1: "Примечания"}), at({1: "Текст без кодов"})], name="Примечания") + + outcome = StatementPipeline(statement_rules).parse( + [(sheet, SheetKind.STATEMENT_CONTINUATION)], NO_DEADLINE, xlsb=False + ) + + assert outcome.documents == () + assert all(isinstance(w, FileWarning) for w in outcome.file_warnings) + assert [(w.code, w.severity, w.locator) for w in outcome.file_warnings] == [ + ("SHEET_OTHER", "info", "sheet:Примечания") + ] + + +def test_xlsb_flag_adds_document_warning(statement_rules: StatementRules) -> None: + 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} diff --git a/api/tests/unit/parser/test_book_parser.py b/api/tests/unit/parser/test_book_parser.py index 87281f8..0f7a2be 100644 --- a/api/tests/unit/parser/test_book_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -761,10 +761,19 @@ def test_documents_order_osv_then_forms(rules: Rules) -> None: assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument"] +def test_consistent_forms_book_is_done(rules: Rules) -> None: + """ББ и ОФР с сошедшимися соотношениями: оба документа `done`, предупреждений нет, книга `done`.""" + outcome = book(rules).parse(xlsx({"ББ": GOOD_BALANCE_ROWS, "ОФР": GOOD_INCOME_ROWS}), "XLSX") + + assert [(d.document_type, d.status) for d in outcome.documents] == [("BALANCE", "done"), ("INCOME", "done")] + assert outcome.status is ResultStatus.DONE + assert_valid_result(outcome) + + def test_statement_error_warning_makes_book_needs_check(rules: Rules) -> None: """Книга `done` ⇔ все документы `done` и нет предупреждений файла `error` (спека «Статус книги»).""" - broken = [row for row in BALANCE_ROWS] - broken[-1] = ["БАЛАНС", "1600", 999] # 1600 ≠ 1100 + 1200 → жёсткое соотношение + broken = [row[:] for row in GOOD_BALANCE_ROWS] + broken[-1][2] = 999 # 1600 ≠ 1100 + 1200 → жёсткое соотношение outcome = book(rules).parse(xlsx({"ББ": broken}), "XLSX") @@ -824,14 +833,16 @@ def test_xlsb_form_scale_suspect(rules: Rules) -> None: def test_xlsb_form_without_fractions_has_only_no_formats(rules: Rules) -> None: - whole = [row[:] for row in BALANCE_ROWS] + whole = [row[:] for row in GOOD_BALANCE_ROWS] whole.insert(3, ["Единица измерения", "тыс. руб."]) + whole.insert(3, ["ИНН", "7700000000"]) outcome = book(rules).parse(_xlsb_book({"ББ": whole}), "XLSB") (document,) = outcome.documents - assert "XLSB_SCALE_SUSPECT" not in {w.code for w in document.warnings} - assert "XLSB_NO_FORMATS" in {w.code for w in document.warnings} + assert [w.code for w in document.warnings] == ["XLSB_NO_FORMATS"] + assert document.status == "done" + assert outcome.status is ResultStatus.DONE def test_xlsb_book_marks_osv_with_outline_note_only(rules: Rules) -> None: @@ -864,6 +875,24 @@ BALANCE_ROWS: list[Cells] = [ ["Итого по разделу II", "1200", 50], ["БАЛАНС", "1600", 150], ] +GOOD_BALANCE_ROWS: list[Cells] = [ + *BALANCE_ROWS, + ["Нераспределенная прибыль", "1370", 150], + ["Итого по разделу III", "1300", 150], + ["БАЛАНС", "1700", 150], +] +GOOD_INCOME_ROWS: list[Cells] = [ + ["Отчет о финансовых результатах"], + ["за 1 квартал 2025 г."], + ["Организация", "ООО Ромашка"], + ["Наименование показателя", "Код", "За 1 квартал 2025 г."], + ["Выручка", "2110", 1000], + ["Себестоимость продаж", "2120", -600], + ["Валовая прибыль (убыток)", "2100", 400], + ["Прибыль (убыток) от продаж", "2200", 400], + ["Прибыль (убыток) до налогообложения", "2300", 400], + ["Чистая прибыль (убыток)", "2400", 400], +] INCOME_ROWS: list[Cells] = [ ["Отчет о финансовых результатах"], ["за 1 квартал 2025 г."], @@ -914,10 +943,26 @@ def test_file_warnings_before_osv_error_are_kept(rules: Rules) -> None: def test_file_warnings_follow_sheet_order(rules: Rules) -> None: + """Предупреждения идут по порядку листов: «Справка» (до ББ) и «Хвост» (после ОФР) не уезжают относительно форм.""" notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] - xlsb_book = xlsx({"ББ": BALANCE_ROWS, "Примечания": notes, "ОФР": INCOME_ROWS}) + tail: list[Cells] = [["Хвост"], ["Текст без кодов и сумм"]] + unjoined = [["БАЛАНС", "1700", 150]] # одна кодовая строка после листа «прочее» — не продолжение + content = xlsx( + { + "Справка": notes, + "ББ": BALANCE_ROWS, + "Лист_1": mini_osv(), + "Одиночка": unjoined, + "ОФР": INCOME_ROWS, + "Хвост": tail, + } + ) - outcome = book(rules).parse(xlsb_book, "XLS") + outcome = book(rules).parse(content, "XLS") # xlsx под видом XLS → FILE_TYPE_MISMATCH первым - assert file_codes_of(outcome)[0] == "FILE_TYPE_MISMATCH" - assert [w.locator for w in file_warnings_of(outcome)[1:]][:1] == ["sheet:Примечания"] + assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [ + ("FILE_TYPE_MISMATCH", None), + ("SHEET_OTHER", "sheet:Справка"), + ("SHEET_OTHER", "sheet:Одиночка"), + ("SHEET_OTHER", "sheet:Хвост"), + ]