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
325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""Заголовок таблицы формы, колонка кодов, наименований и периодов (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
|