test: согласованные формы для статуса книги, test_pipeline, флаг BookRead.no_outline (fix round 1, #54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 21:01:53 +04:00
parent 71f596cdb7
commit 9e694796ad
4 changed files with 145 additions and 12 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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}

View File

@ -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:Хвост"),
]