Andrey Epifancev 1abe212524 fix(statements): финальная волна ревью — I-1 (O-9), M-10, I-2, I-3, M-5, M-8, M-11, T13.5, M-6, M-7, T15.1, T15.2
I-1: лист с 1–2 кодами после листа формы — продолжение (min_codes_continuation в словаре).
M-10: тип формы маркера продолжения запрещает присоединение к документу иного типа.
I-2: одиночный месяц не задаёт границы при дате на число/квартале (not_single_month).
I-3: название строки из ближайшей текстовой ячейки левее кодов, пустое имя без confidence.
M-5: units_thousands и okud_code в словаре, без type: ignore в detect.py.
M-8: строка нумерации и нули не считаются строкой без кода с суммами.
M-11: предупреждение о дробных значениях — одно на документ.
T13.5: _upper_header_row возвращает верхнюю строку при склейке хотя бы одной колонки.
M-6/M-7/T15.1/T15.2: остатки statement_markers, описание fractional_share_percent, README, тест воркера.

Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 21:58:24 +04:00

325 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.

"""Заголовок таблицы формы, колонка кодов, наименований и периодов (design D-4; spec «Заголовок таблицы, колонка кодов
и колонки-периоды определяются по тексту»)."""
import datetime
from decimal import Decimal
import pytest
from gridbuilder import at, sheet_from_rows
from src.parser.grid import Cell
from src.parser.rules import Rules, load_rules
from src.parser.statements.columns import (
LayoutNotFoundError,
find_code_column,
find_layout,
line_code,
)
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
# Test constants
CODE_COL_ONE_C = 4
NAME_COL_ONE_C = 2
CODE_COL_TDSHEET = 2
NAME_COL_TDSHEET = 1
HEADER_ROW_ONE_C = 4
FIRST_CODE_ROW_ONE_C = 5
PERIOD_COL_ONE_C_1 = 5
PERIOD_COL_ONE_C_2 = 6
HEADER_ROW_TDSHEET = 2
HEADER_LAST_ROW_TDSHEET = 3
FIRST_CODE_ROW_TDSHEET = 4
HEADER_ROW_TWO_ROW = 1
HEADER_LAST_ROW_TWO_ROW = 2
FIRST_CODE_ROW_TWO_ROW = 3
HEADER_ROW_NO_CAPTION = 1
CODE_COL_NO_CAPTION = 2
NAME_COL_NO_CAPTION = 1
HEADER_ROW_CONTINUATION = None
FIRST_DATA_ROW_CONTINUATION = 1
CODE_COL_CONTINUATION = 6
PERIOD_COL_CONT_1 = 7
PERIOD_COL_CONT_2 = 8
PERIOD_COL_CONT_3 = 9
HEADER_ROW_BARE_YEAR = 2
PERIOD_COL_BARE_YEAR_1 = 15
PERIOD_COL_BARE_YEAR_2 = 20
def test_line_code_from_text_and_numeric_cells(rules: Rules) -> None:
assert line_code(Cell("1110", "text"), rules) == "1110"
assert line_code(Cell(Decimal("1110"), "number"), rules) == "1110"
assert line_code(Cell(Decimal("1110.0"), "number"), rules) == "1110" # Review Focus 1: xlrd даёт float
assert line_code(Cell("11101", "text"), rules) == "11101"
assert line_code(Cell("2110", "text"), rules) == "2110"
assert line_code(Cell("5200", "text"), rules) == "5200"
assert line_code(Cell("2023", "text"), rules) is None # год, не код
assert line_code(Cell("0710001", "text"), rules) is None
assert line_code(Cell("", "empty"), rules) is None
# Test data: ONE_C_BALANCE sheet
_HEADER_ROW_ONE_C = [
"Пояснения",
"Наименование показателя",
None,
"Код",
"На 31 декабря 2024 г.",
"На 31 декабря 2023 г.",
"Изменение, %",
]
ONE_C_BALANCE = [
["Бухгалтерский баланс"],
["на 31 декабря 2024 г."],
[],
_HEADER_ROW_ONE_C,
[None, "АКТИВ"],
[None, "Нематериальные активы", None, "1110", 5971264, 27405634, 12],
[None, "Нематериальные активы в организации", None, "11101", 5852464, 27214614, 0],
[None, None, None, None, 0, 0, 0],
["1", "Основные средства", None, "1150", 2039077663, 1809528568, 7],
[None, "Итого по разделу I", None, "1100", 2045048927, 1836934202, 7],
]
def test_code_column_by_share(rules: Rules) -> None:
sheet = sheet_from_rows(ONE_C_BALANCE)
assert find_code_column(sheet, rules, 1, sheet.n_rows) == CODE_COL_ONE_C
def test_code_column_needs_three_codes_and_sixty_percent(rules: Rules) -> None:
sheet = sheet_from_rows([["x", "1110"], ["y", "abc"], ["z", "def"], ["w", "ghi"], ["v", "1150"]])
assert find_code_column(sheet, rules, 1, 5) is None
def test_layout_one_c(rules: Rules) -> None:
sheet = sheet_from_rows(ONE_C_BALANCE)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (
HEADER_ROW_ONE_C,
HEADER_ROW_ONE_C,
FIRST_CODE_ROW_ONE_C,
)
assert layout.code_col == CODE_COL_ONE_C and layout.name_col == NAME_COL_ONE_C
expected_periods = [
(PERIOD_COL_ONE_C_1, "На 31 декабря 2024 г.", datetime.date(2024, 12, 31)),
(PERIOD_COL_ONE_C_2, "На 31 декабря 2023 г.", datetime.date(2023, 12, 31)),
]
assert [(column.col, column.label, column.period_date) for column in layout.periods] == expected_periods
assert layout.skipped_labels == ("Изменение, %",)
def test_layout_tdsheet_with_numbering_row_and_line_breaks(rules: Rules) -> None:
sheet = sheet_from_rows(
[
["БУХГАЛТЕРСКИЙ БАЛАНС"],
["Наименование показателя", "Коды", "За 9 месяцев\n2025 года", "На 31 декабря \n2024 года"],
["1", "2", "3", "4"],
["Нематериальные активы", "1110", 0, 0],
["Основные средства", "1150", Decimal("898852.21795"), 966270],
["ИТОГО по разделу I", "1100", Decimal("898852.21795"), 966270],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (
HEADER_ROW_TDSHEET,
HEADER_LAST_ROW_TDSHEET,
FIRST_CODE_ROW_TDSHEET,
)
assert layout.name_col == NAME_COL_TDSHEET and layout.code_col == CODE_COL_TDSHEET
expected_labels = ["За 9 месяцев 2025 года", "На 31 декабря 2024 года"]
assert [column.label for column in layout.periods] == expected_labels
assert layout.periods[0].period_date == datetime.date(2025, 9, 30)
def test_layout_two_row_header(rules: Rules) -> None:
sheet = sheet_from_rows(
[
["Наименование показателя", "Код", "На 31 декабря", "На 31 декабря"],
[None, None, "2024 г.", "2023 г."],
["Запасы", "1210", 10, 20],
["Дебиторская задолженность", "1230", 30, 40],
["Итого по разделу II", "1200", 40, 60],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (
HEADER_ROW_TWO_ROW,
HEADER_LAST_ROW_TWO_ROW,
FIRST_CODE_ROW_TWO_ROW,
)
expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)]
assert [column.period_date for column in layout.periods] == expected_dates
def test_layout_without_code_caption(rules: Rules) -> None:
sheet = sheet_from_rows(
[
["Показатель", None, "На 31.12.2024", "На 31.12.2023"],
["Основные средства", "1150", 100, 90],
["Запасы", "1210", 50, 40],
["БАЛАНС", "1600", 150, 130],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == HEADER_ROW_NO_CAPTION
assert layout.code_col == CODE_COL_NO_CAPTION
assert layout.name_col == NAME_COL_NO_CAPTION
expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)]
assert [column.period_date for column in layout.periods] == expected_dates
def test_layout_two_row_header_without_code_caption_keeps_upper_row(rules: Rules) -> None:
"""T13.I2: без подписи «Код» верхняя строка двухстрочного заголовка не теряется (st02): «Изменение, %» пропущена."""
sheet = sheet_from_rows(
[
["Показатель", None, "На 31 декабря", "На 31 декабря", "Изменение, %"],
[None, None, "2024 г.", "2023 г."],
["Основные средства", "1150", 100, 90, 5],
["Итого по разделу I", "1100", 100, 90, 5],
["Запасы", "1210", 50, 40, 5],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 1
assert layout.first_data_row == 3 # noqa: PLR2004
expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)]
assert [column.period_date for column in layout.periods] == expected_dates
assert [column.label for column in layout.periods] == ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."]
assert layout.skipped_labels == ("Изменение, %",)
def test_layout_document_header_above_dates_is_not_upper_header_row(rules: Rules) -> None:
"""Строка шапки документа над датами («Единица измерения…» правее кодов, без чисел) — не часть заголовка."""
sheet = sheet_from_rows(
[
["Бухгалтерский баланс"],
[None, None, "Единица измерения: тыс. руб."],
[None, None, "2024 г.", "2023 г."],
["Основные средства", "1150", 100, 90],
["Итого по разделу I", "1100", 100, 90],
["Запасы", "1210", 50, 40],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 3 # noqa: PLR2004
assert layout.skipped_labels == ()
assert len(layout.periods) == 2 # noqa: PLR2004
def test_layout_upper_row_kept_when_a_foreign_label_sits_above_another_column(rules: Rules) -> None:
"""T13.5: верхняя строка берётся, если хоть одна колонка в ней склеилась в период, хотя над соседней — подпись
без периода («Примечание»)."""
sheet = sheet_from_rows(
[
["Показатель", None, "На 31 декабря", "На 31 декабря", "Примечание"],
[None, None, "2024 г.", "2023 г.", "Изм."],
["Основные средства", "1150", 100, 90, 5],
["Итого по разделу I", "1100", 100, 90, 5],
["Запасы", "1210", 50, 40, 5],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 1
assert [column.period_date for column in layout.periods] == [
datetime.date(2024, 12, 31),
datetime.date(2023, 12, 31),
]
assert layout.skipped_labels == ("Примечание Изм.",)
def test_layout_units_label_above_a_complete_date_is_not_glued(rules: Rules) -> None:
"""T13.3/T13.5 (в обратную сторону): «Единица измерения» над уже полной датой в той же колонке — не заголовок."""
sheet = sheet_from_rows(
[
[None, None, "Единица измерения: тыс. руб."],
[None, None, "на 31 декабря 2024 г."],
["Основные средства", "1150", 100],
["Итого по разделу I", "1100", 100],
["Запасы", "1210", 50],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 2 # noqa: PLR2004
assert [column.label for column in layout.periods] == ["на 31 декабря 2024 г."]
def test_layout_continuation_without_header(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[None, None, None, None, "в том числе:"],
[None, None, None, None, "Биологические активы", "11901", 0, 0, 0],
[None, None, None, None, "Итого по разделу I", "1100", 11853698344, 11570097340, 9391157985],
[None, None, None, "II. Оборотные активы"],
[None, None, None, "Запасы", None, "1210", 1001705104, 1426756617, 3393013092],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row is HEADER_ROW_CONTINUATION
assert layout.first_data_row == FIRST_DATA_ROW_CONTINUATION
assert layout.code_col == CODE_COL_CONTINUATION
expected_cols = [
(PERIOD_COL_CONT_1, "", None),
(PERIOD_COL_CONT_2, "", None),
(PERIOD_COL_CONT_3, "", None),
]
assert [(column.col, column.label, column.period_date) for column in layout.periods] == expected_cols
def test_explanations_column_is_not_name(rules: Rules) -> None:
sheet = sheet_from_rows(ONE_C_BALANCE)
assert find_layout(sheet, rules, 1, sheet.n_rows).name_col == NAME_COL_ONE_C
def test_no_code_column_raises(rules: Rules) -> None:
sheet = sheet_from_rows([["Показатель", "Сумма"], ["Основные средства", 100], ["Запасы", 50]])
with pytest.raises(LayoutNotFoundError, match="колонка кодов не найдена"):
find_layout(sheet, rules, 1, 3)
def test_bare_year_in_title_block_is_not_a_header(rules: Rules) -> None:
"""У НПЗ над таблицей стоит «Отчетная дата | 30 | 09 | 2025»: строка с подписью «Код» побеждает."""
sheet = sheet_from_rows(
[
[*at({6: "Отчетная дата", 22: "30", 24: "09", 25: "2025"})],
[*at({4: "Наименование показателя", 12: "Код", 15: "На 30 сентября 2025 г.", 20: "На 31 декабря 2024 г."})],
[*at({4: "Гудвил", 12: "1105", 15: 0, 20: 0})],
[*at({4: "Нематериальные активы", 12: "1110", 15: 586260, 20: 905786})],
[*at({4: "Итого по разделу I", 12: "1100", 15: 586260, 20: 905786})],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
period_cols = [PERIOD_COL_BARE_YEAR_1, PERIOD_COL_BARE_YEAR_2]
assert layout.header_row == HEADER_ROW_BARE_YEAR
assert [column.col for column in layout.periods] == period_cols