Andrey Epifancev 388276c261 ББ/ОФР из Excel: значение ячейки — как показывает числовой формат (#42)
statement_money вместо to_money в разборе строк форм, предупреждения о неразобранном формате и об xlsb,
сравнение условий формата с точностью Excel (15 значащих цифр), тесты и тексты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:26:36 +04:00

457 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# API/tests/unit/parser/statements/test_statement.py
"""Конвейер форм: блоки, склейка продолжений, статус документа (design D-8; spec «Склейка листов-продолжений и статус
документа»). Книги собираются `gridbuilder`, вид листа задаётся явно, как это делает `BookParser`."""
from decimal import Decimal
import pytest
from gridbuilder import at, sheet_from_rows, with_formats
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind
from src.parser.rules import load_rules
from src.parser.statements.statement import CONTINUATION_WARNING, StatementParser
TWO_COLUMNS = TWO_DOCUMENTS = 2
@pytest.fixture(scope="module")
def parser() -> StatementParser:
return StatementParser(load_rules())
def deadline() -> Deadline:
return Deadline(120)
BALANCE_PAGE_1 = [
["Бухгалтерский баланс"],
["на 31 декабря 2024 г."],
["Форма по ОКУД", None, "0710001"],
["Организация", None, 'ООО "Пример"', "ИНН", "7700000000"],
["Единица измерения:", None, "тыс. руб."],
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Основные средства", "1150", 100, 90],
["Итого по разделу I", "1100", 100, 90],
["Запасы", "1210", 50, 40],
["Итого по разделу II", "1200", 50, 40],
["БАЛАНС", "1600", 150, 130],
]
BALANCE_PAGE_2 = [
[*at({6: "Форма 0710001 с.2"})],
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Уставный капитал", "1310", 150, 130],
["Итого по разделу III", "1300", 150, 130],
["БАЛАНС", "1700", 150, 130],
]
INCOME = [
["Отчет о финансовых результатах"],
["за 2024 г."],
["Организация", None, 'ООО "Пример"'],
["Единица измерения:", None, "тыс. руб."],
["Наименование показателя", "Код", "За 2024 г."],
["Выручка", "2110", 1000],
["Себестоимость продаж", "2120", -600],
["Валовая прибыль (убыток)", "2100", 400],
["Коммерческие расходы", "2210", -50],
["Управленческие расходы", "2220", -50],
["Прибыль (убыток) от продаж", "2200", 300],
["Проценты к уплате", "2330", -100],
["Прибыль (убыток) до налогообложения", "2300", 200],
["Налог на прибыль", "2410", -40],
["Чистая прибыль (убыток)", "2400", 160],
]
# Полный набор: 2200 = 2100 − 2210 − 2220 и 2300 = 2200 − 2330 сходятся через abs_terms (знаки 1С), 2400 — мягкое
NPZ_CONTINUATION = [
["в том числе:"],
["Биологические активы", "11901", 0, 0],
["Расходы будущих периодов", "11903", 0, 0],
["Прочие внеоборотные активы", "1190", 0, 0],
]
def test_full_balance_over_two_sheets_is_done(parser: StatementParser) -> None:
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), SheetKind.STATEMENT),
(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, warnings, ok = parser.parse_sheets(sheets, deadline())
assert ok and len(documents) == 1
document = documents[0]
assert document.document_type == "BALANCE" and document.locator == "sheet:ББ1,ББ2"
assert [line.line_code for line in document.lines] == [
"1150",
"1100",
"1210",
"1200",
"1600",
"1310",
"1300",
"1700",
]
assert document.checks is not None and document.checks.relations_failed == []
assert document.header.inn == "7700000000" and document.header.units == "тыс. руб."
assert len(document.columns) == TWO_COLUMNS and document.columns[0].label == "На 31 декабря 2024 г."
def test_page_one_alone_fails_total_relation(parser: StatementParser) -> None:
documents, warnings, ok = parser.parse_sheets([(sheet_from_rows(BALANCE_PAGE_1), SheetKind.STATEMENT)], deadline())
assert not ok
assert [item.relation for item in documents[0].checks.relations_failed] == ["1600 = 1700", "1600 = 1700"]
assert any("1600 = 1700" in warning for warning in warnings)
def test_lonely_continuation_is_needs_check(parser: StatementParser) -> None:
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(BALANCE_PAGE_2, "с.2"), SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and documents[0].document_type == "BALANCE"
assert documents[0].header.organization_name is None and documents[0].header.form_name == "Форма 0710001 с.2"
assert [line.line_code for line in documents[0].lines] == ["1310", "1300", "1700"]
assert f"sheet=с.2 {CONTINUATION_WARNING}" in warnings
def test_continuation_joins_last_open_document(parser: StatementParser) -> None:
"""Review Focus 4: продолжение после ОФР относится к ОФР, а не к балансу."""
income_tail = [
["Налог", "2450", 0],
["Прочее", "2460", 0],
["Совокупный финансовый результат периода", "2500", 160],
]
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT),
(sheet_from_rows(income_tail, "ОФР_2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, _, _ = parser.parse_sheets(sheets, deadline())
assert [document.document_type for document in documents] == ["BALANCE", "INCOME"]
assert documents[1].locator == "sheet:ОФР,ОФР_2" and [line.line_code for line in documents[1].lines][-2:] == [
"2460",
"2500",
]
def test_continuation_without_header_maps_value_columns_by_order(parser: StatementParser) -> None:
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(NPZ_CONTINUATION, "ББ_2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, _, _ = parser.parse_sheets(sheets, deadline())
tail = documents[0].lines[-1]
assert tail.line_code == "1190" and [value.column_index for value in tail.values] == [0, 1]
assert [line.line_code for line in documents[0].lines][-3:] == ["11901", "11903", "1190"]
def test_continuation_with_other_column_count_opens_new_document(parser: StatementParser) -> None:
three_columns = [
["Форма 0710001 с.2"],
["Наименование", "Код", "На 31.12.2024", "На 31.12.2023", "На 31.12.2022"],
["Уставный капитал", "1310", 1, 1, 1],
["Добавочный капитал", "1350", 1, 1, 1],
["Итого по разделу III", "1300", 2, 2, 2],
]
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(three_columns, "с.2"), SheetKind.STATEMENT_CONTINUATION),
]
documents, warnings, ok = parser.parse_sheets(sheets, deadline())
assert len(documents) == TWO_DOCUMENTS and not ok and any(CONTINUATION_WARNING in warning for warning in warnings)
def test_two_forms_on_one_sheet(parser: StatementParser) -> None:
sheet = sheet_from_rows([*BALANCE_PAGE_1, *BALANCE_PAGE_2[2:], [], *INCOME], "Отчетность")
documents, _, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert [document.document_type for document in documents] == ["BALANCE", "INCOME"] and ok
assert all(document.locator == "sheet:Отчетность" for document in documents)
assert "2110" not in [line.line_code for line in documents[0].lines]
def test_disclosure_only_book_is_done(parser: StatementParser) -> None:
"""Review Focus 5."""
sheet = sheet_from_rows(
[
["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"],
["1. Нематериальные активы"],
["текст"],
],
"Пояснения",
)
documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert (
ok and documents[0].document_type == "DISCLOSURE" and documents[0].lines == [] and documents[0].checks is None
)
def test_no_code_column_is_needs_check_with_warning(parser: StatementParser) -> None:
sheet = sheet_from_rows(
[
["Бухгалтерский баланс"],
["Организация", "ООО Х"],
["Показатель", "На 31.12.2024"],
["Основные средства", 100],
["Запасы", 50],
["БАЛАНС", 150],
],
"ББ",
)
documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert not ok and documents[0].lines == [] and documents[0].columns == []
assert "sheet=ББ колонка кодов не найдена" in warnings
def test_duplicate_code_is_needs_check(parser: StatementParser) -> None:
rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1]]
documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert not ok and documents[0].checks.duplicate_line_codes == ["1210"]
def test_template_without_sums_is_needs_check(parser: StatementParser) -> None:
rows = [
["Бухгалтерский баланс"],
["Наименование показателя", "Код", "На 31 декабря 2024 г."],
["Основные средства", "1150"],
["Запасы", "1210"],
["БАЛАНС", "1600"],
]
documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert (
not ok
and documents[0].checks.relations_failed == []
and all(value.amount is None for line in documents[0].lines for value in line.values)
)
def test_duplicate_codes_are_unique_in_order_of_first_duplicate(parser: StatementParser) -> None:
"""spec: `duplicate_line_codes` — уникальные коды; повтор на каждое вхождение не плодит записей (T11-ревью)."""
rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1], ["Запасы", "1210", 2, 2], ["Основные средства", "1150", 1, 1]]
documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert not ok and documents[0].checks.duplicate_line_codes == ["1210", "1150"]
def test_code_repeated_on_continuation_is_duplicate_and_first_value_kept(parser: StatementParser) -> None:
first = (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT)
again = [
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Запасы", "1210", 7, 7],
["Итого", "1200", 50, 40],
["Прочее", "1230", 0, 0],
]
documents, _, ok = parser.parse_sheets(
[first, (sheet_from_rows(again, "ББ3"), SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and documents[0].checks.duplicate_line_codes == ["1210", "1200"]
assert [line.values[0].amount for line in documents[0].lines if line.line_code == "1210"] == [Decimal(50)]
def test_headerless_lonely_continuation_has_empty_header(parser: StatementParser) -> None:
documents, _, ok = parser.parse_sheets(
[(sheet_from_rows(NPZ_CONTINUATION, "хвост"), SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and documents[0].document_type == "OTHER" and documents[0].header.form_name is None
def test_continuation_without_code_column_is_dropped_and_not_ok(parser: StatementParser) -> None:
"""Потерянный лист нельзя оставлять статусом `done`."""
lost = [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]]
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION),
(sheet_from_rows(lost, "с.3"), SheetKind.STATEMENT_CONTINUATION),
]
documents, warnings, ok = parser.parse_sheets(sheets, deadline())
assert (
not ok
and len(documents) == 1
and documents[0].checks.relations_failed == []
and "sheet=с.3 колонка кодов не найдена" in warnings
)
def test_block_with_okud_but_without_title_has_no_form_name(parser: StatementParser) -> None:
"""Блок, открытый только кодом ОКУД: `form_name` не передаётся (в контракте нет null)."""
rows = [["Форма по ОКУД", None, "0710001"], *BALANCE_PAGE_1[3:]]
documents, _, _ = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline())
assert documents[0].header.form_name is None and documents[0].header.okud == "0710001"
def _fractional_sheet(name: str, units: bool, rows: list[list[object]]) -> tuple:
head = [["Бухгалтерский баланс"], ["Организация", None, 'ООО "Пример"']]
if units:
head.append(["Единица измерения:", None, "тыс. руб."])
return sheet_from_rows([*head, ["Наименование показателя", "Код", "На 31 декабря 2024 г."], *rows], name)
def test_fractional_warning_is_one_per_document_over_all_sheets(parser: StatementParser) -> None:
"""M-11: две страницы документа — одно предупреждение с суммарными счётчиками."""
first = _fractional_sheet(
"ББ1", True, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100], ["Запасы", "1210", 7]]
)
second = sheet_from_rows(
[
["Форма 0710001 с.2"],
["Наименование показателя", "Код", "На 31 декабря 2024 г."],
["Уставный капитал", "1310", Decimal("50.25")],
["Итого по разделу III", "1300", 50],
["БАЛАНС", "1700", 50],
],
"ББ2",
)
_, warnings, _ = parser.parse_sheets(
[(first, SheetKind.STATEMENT), (second, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
fractional = [text for text in warnings if "дробной частью" in text]
assert fractional == ["sheet=ББ1 значений с дробной частью при единице тыс. руб.: 2 из 6"]
def test_no_fractional_warning_without_thousands_units(parser: StatementParser) -> None:
sheet = _fractional_sheet("ББ", False, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100]])
_, warnings, _ = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline())
assert not any("дробной частью" in text for text in warnings)
def test_single_code_sheet_after_a_form_joins_as_continuation(parser: StatementParser) -> None:
"""O-9 (I-1): лист с одной кодовой строкой (меньше min_codes), идущий после формы, присоединяется."""
single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2")
documents, warnings, _ = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert len(documents) == 1
assert documents[0].locator == "sheet:ОФР,ОФР_2"
assert [line.line_code for line in documents[0].lines][-1] == "2520"
def test_single_code_sheet_that_cannot_join_is_dropped_with_exact_warning(parser: StatementParser) -> None:
"""O-9: число колонок сумм не совпало — лист не присоединён, книга needs_check, предупреждение точное."""
single = sheet_from_rows([["Чистая прибыль (убыток)", "2400", 160, 150]], "ОФР_2")
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and len(documents) == 1
assert (
"sheet=ОФР_2 лист с кодами форм (1 < 3) не присоединён: "
"число колонок сумм 2 не совпадает с открытым документом (1)" in warnings
)
def test_continuation_of_another_form_type_does_not_join(parser: StatementParser) -> None:
"""M-10: маркер «Форма 0710001 с.2» (ББ) не присоединяет лист к открытому ОФР."""
income = sheet_from_rows(INCOME, "ОФР")
balance_page_2 = sheet_from_rows(
[
["Форма 0710001 с.2"],
["Наименование показателя", "Код", "За 2024 г."],
["Уставный капитал", "1310", 150],
["Итого по разделу III", "1300", 150],
["БАЛАНС", "1700", 150],
],
"ББ2",
)
documents, warnings, ok = parser.parse_sheets(
[(income, SheetKind.STATEMENT), (balance_page_2, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert [document.document_type for document in documents] == ["INCOME", "BALANCE"]
assert not ok and CONTINUATION_WARNING in " ".join(warnings)
# --- N-1: слабое продолжение (одна кодовая строка) — коды той же формы, присоединение в предупреждениях ---
def test_single_code_sheet_join_is_reported(parser: StatementParser) -> None:
single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2")
_, warnings, _ = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert "sheet=ОФР_2 присоединён к документу как продолжение по 1 кодовой строке" in warnings
def test_single_code_sheet_of_another_form_does_not_join(parser: StatementParser) -> None:
"""Лист «Справка» с кодом баланса после ОФР — не продолжение ОФР, даже при совпавшем числе колонок."""
single = sheet_from_rows([["Основные средства", "1150", 0]], "Справка")
documents, warnings, ok = parser.parse_sheets(
[(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline()
)
assert not ok and len(documents) == 1 and "1150" not in [line.line_code for line in documents[0].lines]
assert any(
text.startswith("sheet=Справка лист с кодами форм (1 < 3) не присоединён: коды 1150 не из формы INCOME")
for text in warnings
)
def test_unparsed_number_format_gives_one_document_warning(parser: StatementParser) -> None:
long_format = "0.0000%" + "x" * 40
page_1 = with_formats(
sheet_from_rows(BALANCE_PAGE_1, "ББ1"), {(7, 3): "0%", (8, 3): "0%", (9, 3): long_format, (11, 3): "0.00%"}
)
page_2 = with_formats(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), {(3, 3): "0%"})
sheets = [(page_1, SheetKind.STATEMENT), (page_2, SheetKind.STATEMENT_CONTINUATION)]
_, warnings, _ = parser.parse_sheets(sheets, deadline())
found = [text for text in warnings if "формат чисел не разобран" in text]
shortened = long_format[:29] + "…"
assert found == [f"sheet=ББ1 формат чисел не разобран, значения как в ячейках: «0%», «0.00%», «{shortened}»"]
def test_parsed_formats_give_no_format_warning(parser: StatementParser) -> None:
page_1 = with_formats(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), {(7, 3): "#,##0", (8, 3): "0,"})
_, warnings, _ = parser.parse_sheets([(page_1, SheetKind.STATEMENT)], deadline())
assert not any("формат чисел" in text for text in warnings)
def test_xlsb_flag_adds_warning_to_statement_documents_only(parser: StatementParser) -> None:
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT),
]
_, warnings, _ = parser.parse_sheets(sheets, deadline(), formats_unreadable=True)
_, plain, _ = parser.parse_sheets(sheets, deadline())
text = "формат чисел xlsb не читается: значения как в файле"
assert [w for w in warnings if "xlsb" in w] == [f"sheet=ББ {text}", f"sheet=ОФР {text}"]
assert not any("xlsb" in w for w in plain)