Andrey Epifancev 10b5bda550 feat(statements): конвейер форм — блоки, склейка продолжений, статус документа; подключение в get_parser
StatementParser заменяет SkippingStatementParser; продолжение присоединяется к последнему открытому документу;
duplicate_line_codes — уникальные коды; потерянный лист-продолжение (нет колонки кодов) даёт needs_check.

Refs #14

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 18:12:27 +04:00

304 lines
13 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
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"