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:
parent
71f596cdb7
commit
9e694796ad
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
85
api/tests/unit/parser/statements/test_pipeline.py
Normal file
85
api/tests/unit/parser/statements/test_pipeline.py
Normal 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}
|
||||
@ -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:Хвост"),
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user