"""Шапка документа и схема группировок (spec «Шапка документа и схема группировок», `05-domain-rules.md` §1.1, §1.5). Сетки собираются `gridbuilder`; шапка и заголовок таблицы находятся по содержимому, позиции в сетках произвольные. """ import datetime import pytest from gridbuilder import M, at, sheet_from_rows from src.parser.detect import TableHeader, find_table_header from src.parser.grid import Sheet from src.parser.header import DocHeader, build_group_schema, parse_doc_header from src.parser.rules import Rules, load_rules OPENING = "Сальдо на начало периода" TURNOVER = "Обороты за период" CLOSING = "Сальдо на конец периода" MISSING_FIELDS_WARNINGS = 5 # организация, счёт, период, тип данных, валюта VARIANT_2 = 2 DATA_ROW = at({1: "60", 5: 100, 8: 20, 10: 30, 12: 40, 15: 50, 17: 60}) SUBHEAD = ["Дебет", None, None, "Кредит", None, "Дебет", "Кредит", "Дебет", None, "Кредит"] GROUPS_ROW = [M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)] @pytest.fixture(scope="module") def rules() -> Rules: return load_rules() def _table(*label_rows: list[object]) -> list[list[object]]: """Заголовок таблицы варианта 1: строка групп, подзаголовки сумм в строке первой метки, остальные метки ниже.""" first, *rest = label_rows return [ GROUPS_ROW, [*first, *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[len(first) :]], *rest, DATA_ROW, ] def _parse(rows: list[list[object]], rules: Rules) -> tuple[Sheet, TableHeader, DocHeader]: sheet = sheet_from_rows(rows) header = find_table_header(sheet, rules) return sheet, header, parse_doc_header(sheet, header, rules) def _variant1( rules: Rules, *, org: object = 'АО "Ромашка"', title: str, params: object = "Валюта БУ, НУ: RUB" ) -> DocHeader: rows: list[list[object]] = [ [org], [title], [], ["Выводимые данные: БУ (данные бухгалтерского учета)"], [], ["Параметры:", None, params], [], *_table(["Контрагенты"]), ] return _parse(rows, rules)[2] def _title(period: str, account: str = "60") -> str: return f"Оборотно-сальдовая ведомость по счету {account} за {period}" # --- Вариант 1: организация, счёт, период, тип данных, валюта --- def test_variant1_header_fields(rules: Rules) -> None: header = _variant1(rules, title=_title("3 квартал 2024 г."), params="Валюта БУ, НУ: руб.") assert header == DocHeader( organization_name='АО "Ромашка"', account_code_raw="60", period_text="3 квартал 2024 г.", period_year=2024, period_quarter="Q3", is_cumulative=False, period_start=datetime.date(2024, 7, 1), period_end=datetime.date(2024, 9, 30), data_type="BU", currency="RUB", header_variant=1, warnings=(), ) def test_account_with_subaccount_is_kept_as_in_file(rules: Rules) -> None: header = _variant1(rules, title=_title("3 квартал 2024 г.", "58.03")) assert header.account_code_raw == "58.03" @pytest.mark.parametrize("currency", ["RUB", "rub", "руб.", "Руб", "рублей"]) def test_currency_spellings_give_rub(rules: Rules, currency: str) -> None: header = _variant1(rules, title=_title("2024 г."), params=f"Валюта БУ, НУ: {currency}") assert header.currency == "RUB" assert not any("алют" in warning for warning in header.warnings) def test_other_currency_code_is_kept(rules: Rules) -> None: header = _variant1(rules, title=_title("2024 г."), params="Валюта БУ, НУ: USD") assert header.currency == "USD" def test_currency_value_in_neighbour_cell(rules: Rules) -> None: rows: list[list[object]] = [ ["Организация"], [_title("2024 г.")], ["Параметры:", None, "Валюта БУ, НУ:", None, "RUB"], *_table(["Контрагенты"]), ] assert _parse(rows, rules)[2].currency == "RUB" @pytest.mark.parametrize( ("text", "expected"), [ ("БУ (данные бухгалтерского учета)", "BU"), ("НУ (данные налогового учета)", "NU"), ("сумма", "SUM"), ], ) def test_data_type_values(rules: Rules, text: str, expected: str) -> None: rows: list[list[object]] = [ ["Организация"], [_title("2024 г.")], [f"Выводимые данные: {text}"], *_table(["Контрагенты"]), ] assert _parse(rows, rules)[2].data_type == expected # --- Период: все строки таблицы §1.5 --- @pytest.mark.parametrize( ("period", "quarter", "cumulative"), [ ("1 квартал 2025 г.", "Q1", True), ("I кв. 2025 г.", "Q1", True), ("3 месяца 2025 г.", "Q1", True), ("январь-март 2025 г.", "Q1", True), ("01.01.2025 - 31.03.2025", "Q1", True), ("2 квартал 2025 г.", "Q2", False), ("II кв. 2025 г.", "Q2", False), ("полугодие 2025 г.", "Q2", True), ("1 полугодие 2025 г.", "Q2", True), ("6 месяцев 2025 г.", "Q2", True), ("январь-июнь 2025 г.", "Q2", True), ("3 квартал 2025 г.", "Q3", False), ("III кв. 2025 г.", "Q3", False), ("9 месяцев 2025 г.", "Q3", True), ("январь-сентябрь 2025 г.", "Q3", True), ("4 квартал 2025 г.", "Q4", False), ("IV кв. 2025 г.", "Q4", False), ("2025 год", "Q4", True), ("2025 г.", "Q4", True), ("12 месяцев 2025 г.", "Q4", True), ("январь-декабрь 2025 г.", "Q4", True), ("2025 год (годовая)", "Q4", True), ], ) def test_period_table_of_domain_rules(rules: Rules, period: str, quarter: str, cumulative: bool) -> None: header = _variant1(rules, title=_title(period)) assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, cumulative, 2025) assert header.warnings == () def test_period_text_ends_at_year_marker(rules: Rules) -> None: header = _variant1(rules, title=_title("1 квартал 2025 г. (по подразделениям)")) assert header.period_text == "1 квартал 2025 г." def test_period_without_year_marker_runs_to_line_end(rules: Rules) -> None: header = _variant1(rules, title=_title("с 01.01.2025 по 31.03.2025")) assert header.period_text == "с 01.01.2025 по 31.03.2025" assert header.period_quarter == "Q1" def test_unrecognised_period_text_gives_none_and_warning(rules: Rules) -> None: header = _variant1(rules, title=_title("неделю")) assert header.period_text == "неделю" assert (header.period_quarter, header.is_cumulative, header.period_year) == (None, None, None) assert any("период" in warning for warning in header.warnings) # --- Отсутствующие поля шапки --- def test_missing_organization_gives_empty_string_and_warning(rules: Rules) -> None: rows: list[list[object]] = [[_title("2024 г.")], ["Выводимые данные: БУ"], *_table(["Контрагенты"])] header = _parse(rows, rules)[2] assert header.organization_name == "" assert any("организац" in warning for warning in header.warnings) assert header.account_code_raw == "60" def test_empty_head_area_gives_empty_strings_none_and_warnings(rules: Rules) -> None: header = _parse(_table(["Контрагенты"]), rules)[2] assert header.organization_name == "" assert header.account_code_raw == "" assert header.period_text == "" assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None) assert (header.data_type, header.currency, header.header_variant) == (None, None, 1) assert len(header.warnings) == MISSING_FIELDS_WARNINGS assert all(warning for warning in header.warnings) def test_numeric_and_date_cells_in_head_do_not_break_parsing(rules: Rules) -> None: rows: list[list[object]] = [ [45658], # xlsb: даты остаются числами ["Организация", None, 45659], [_title("2024 г.")], *_table(["Контрагенты"]), ] header = _parse(rows, rules)[2] assert header.organization_name == "Организация" assert header.period_quarter == "Q4" def test_organization_is_first_text_cell_that_is_not_a_marker(rules: Rules) -> None: rows: list[list[object]] = [ [], ["Оборотно-сальдовая ведомость"], ["Организация Б"], ["Организация В"], [_title("2024 г.")], *_table(["Контрагенты"]), ] assert _parse(rows, rules)[2].organization_name == "Организация Б" # --- Схема группировок --- def test_group_schema_variant1_account_counterparty_contract(rules: Rules) -> None: sheet, header, doc = _parse( [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])], rules, ) groups, variant = build_group_schema(sheet, header, rules) assert variant == doc.header_variant == 1 assert [(g.type, g.name, g.has_inn) for g in groups] == [ ("ACCOUNT", "Счет", None), ("COUNTERPARTY", "Контрагенты", None), ("CONTRACT", "Договоры", None), ] def test_group_schema_inn_in_separate_cell_of_counterparty_row(rules: Rules) -> None: rows: list[list[object]] = [ ["Организация"], [_title("2024 г.")], *_table(["Подразделение"], [M("Контрагенты", 2), "ИНН"]), ] sheet, header, _ = _parse(rows, rules) groups, _ = build_group_schema(sheet, header, rules) assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)] def test_group_schema_inn_in_the_label_cell(rules: Rules) -> None: rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты, ИНН"])] sheet, header, _ = _parse(rows, rules) groups, _ = build_group_schema(sheet, header, rules) assert [(g.type, g.name, g.has_inn) for g in groups] == [ ("ACCOUNT", "Счет", None), ("COUNTERPARTY", "Контрагенты, ИНН", True), ] def test_first_group_is_account_whatever_its_text_says(rules: Rules) -> None: rows: list[list[object]] = [ ["Организация"], [_title("2024 г.")], [M("Подразделение", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)], ["Контрагенты", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]], DATA_ROW, ] sheet, header, _ = _parse(rows, rules) groups, _ = build_group_schema(sheet, header, rules) assert [(g.type, g.name) for g in groups] == [("ACCOUNT", "Подразделение"), ("COUNTERPARTY", "Контрагенты")] def test_unknown_group_text_is_other(rules: Rules) -> None: rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Основные средства"])] sheet, header, _ = _parse(rows, rules) groups, _ = build_group_schema(sheet, header, rules) assert [g.type for g in groups] == ["ACCOUNT", "OTHER"] def test_without_inn_cell_has_inn_stays_unset(rules: Rules) -> None: rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])] sheet, header, _ = _parse(rows, rules) groups, _ = build_group_schema(sheet, header, rules) assert all(g.has_inn is None for g in groups) # --- Вариант 2 --- def _variant2_rows(detail: str = "Детализация по субсчетам, субконто: Контрагенты, Договоры") -> list[list[object]]: return [ ["ООО Вторая"], ["Оборотно-сальдовая ведомость по счету 60"], ["Период: 1 квартал 2026 г."], [detail], ["Выводимые данные: сумма"], [M("Субконто", 4), *GROUPS_ROW[1:]], ["", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]], DATA_ROW, ] def test_variant2_header(rules: Rules) -> None: sheet, header, doc = _parse(_variant2_rows(), rules) groups, variant = build_group_schema(sheet, header, rules) assert variant == doc.header_variant == VARIANT_2 assert [(g.type, g.name) for g in groups] == [ ("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Контрагенты"), ("CONTRACT", "Договоры"), ] assert doc.organization_name == "ООО Вторая" assert doc.account_code_raw == "60" assert (doc.period_text, doc.period_year, doc.period_quarter, doc.is_cumulative) == ( "1 квартал 2026 г.", 2026, "Q1", True, ) assert doc.data_type == "SUM" assert doc.currency is None assert any("алют" in warning for warning in doc.warnings) def test_variant2_single_item(rules: Rules) -> None: sheet, header, _ = _parse(_variant2_rows("Детализация по субсчетам, субконто: Контрагенты"), rules) groups, variant = build_group_schema(sheet, header, rules) assert variant == VARIANT_2 assert [g.type for g in groups] == ["ACCOUNT", "COUNTERPARTY"] def test_variant2_detail_row_is_not_taken_for_organization(rules: Rules) -> None: rows = _variant2_rows() rows[0] = [] doc = _parse(rows, rules)[2] assert doc.organization_name == "" # --- Название организации со словами «за» и «валюта» (T5.1) --- @pytest.mark.parametrize("organization", ["ООО «За Родину»", "Валюта Трейд", "За Урожай ЗАО"]) def test_organization_name_with_marker_words_does_not_hijack_period_or_currency( rules: Rules, organization: str ) -> None: header = _variant1(rules, org=organization, title=_title("3 квартал 2024 г.")) assert header.organization_name == organization assert header.period_text == "3 квартал 2024 г." assert (header.period_year, header.period_quarter, header.is_cumulative) == (2024, "Q3", False) assert header.currency == "RUB" assert header.warnings == () def test_organization_with_currency_word_and_no_currency_row_is_still_organization(rules: Rules) -> None: rows: list[list[object]] = [["Валюта Трейд"], [_title("2024 г.")], *_table(["Контрагенты"])] header = _parse(rows, rules)[2] assert header.organization_name == "Валюта Трейд" assert header.currency is None assert any("алют" in warning for warning in header.warnings) assert header.period_quarter == "Q4" # --- Диапазоны дат с «г.» (T5.2) --- @pytest.mark.parametrize( ("period", "quarter"), [ ("01.01.2025 г. - 31.03.2025 г.", "Q1"), ("с 01.01.2025 г. по 30.06.2025 г.", "Q2"), ("01.01.2025 г. по 31.12.2025 г.", "Q4"), ("01.01.2025 - 30.09.2025 г. (по подразделениям)", "Q3"), ], ) def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, quarter: str) -> None: header = _variant1(rules, title=_title(period)) assert header.period_text == period.removesuffix(" (по подразделениям)") assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, True, 2025) assert header.warnings == () # --- Диапазон месяцев с годами: скользящий период (issue #11 п. 1, OQ-131) --- @pytest.mark.parametrize( "period", [ "Октябрь 2024 г. - Сентябрь 2025 г.", "с октября 2024 г. по сентябрь 2025 г.", "октябрь 2024 - сентябрь 2025", "Октябрь 2024 г. - Сентябрь 2025 г. (по подразделениям)", ], ) def test_rolling_period_is_not_read_as_first_month_quarter(rules: Rules, period: str) -> None: header = _variant1(rules, title=_title(period)) assert header.period_text == period.removesuffix(" (по подразделениям)") # Конец диапазона определяет год; квартал и «нарастающий» не определены: 12 месяцев — не квартал и не с начала года assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None) assert any("не с начала года" in warning and "12 мес" in warning for warning in header.warnings) def test_month_range_of_one_quarter_is_a_quarter_not_cumulative(rules: Rules) -> None: header = _variant1(rules, title=_title("Апрель 2025 г. - Июнь 2025 г.")) assert header.period_text == "Апрель 2025 г. - Июнь 2025 г." assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q2", False) assert header.warnings == () def test_month_range_inside_year_not_from_january_is_not_a_quarter(rules: Rules) -> None: header = _variant1(rules, title=_title("Апрель 2025 г. - Сентябрь 2025 г.")) assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None) assert any("не с начала года" in warning for warning in header.warnings) def test_month_range_from_january_to_quarter_end_stays_cumulative(rules: Rules) -> None: header = _variant1(rules, title=_title("Январь 2025 г. - Сентябрь 2025 г.")) assert header.period_text == "Январь 2025 г. - Сентябрь 2025 г." assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q3", True) assert header.warnings == () def test_month_range_from_january_to_mid_quarter_has_no_quarter(rules: Rules) -> None: header = _variant1(rules, title=_title("Январь 2025 г. - Май 2025 г.")) assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None) assert any("период" in warning for warning in header.warnings) # --- Даты числами / датами (xlsb) (T5.5) --- def test_period_carried_only_by_date_cells_is_not_read(rules: Rules) -> None: """Период «за» + ячейки-даты (xlsb не отдаёт форматы, даты — числа): текста периода нет, это не ошибка разбора.""" rows: list[list[object]] = [ ["Организация"], ["Оборотно-сальдовая ведомость по счету 60 за", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31)], *_table(["Контрагенты"]), ] header = _parse(rows, rules)[2] assert header.organization_name == "Организация" assert header.account_code_raw == "60" assert header.period_text == "" assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None) assert any("период" in warning for warning in header.warnings) # --- Вариант 2: ИНН (T5.4) --- def _variant2_with_inn_column() -> list[list[object]]: rows = _variant2_rows() rows[6] = [ "", None, "ИНН", None, *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[4:], ] return rows def test_variant2_inn_column_marks_the_first_counterparty(rules: Rules) -> None: sheet, header, _ = _parse(_variant2_with_inn_column(), rules) groups, _ = build_group_schema(sheet, header, rules) assert header.inn_col is not None assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("COUNTERPARTY", True), ("CONTRACT", None)] def test_variant2_without_inn_column_leaves_has_inn_unset(rules: Rules) -> None: sheet, header, _ = _parse(_variant2_rows(), rules) groups, _ = build_group_schema(sheet, header, rules) assert header.inn_col is None assert all(g.has_inn is None for g in groups) # --- Границы периода (контракт v1.2, OQ-131) --- @pytest.mark.parametrize( ("period_text", "start", "end", "from_year_start"), [ ("1 квартал 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31), True), ("3 квартал 2025 г.", datetime.date(2025, 7, 1), datetime.date(2025, 9, 30), False), ("9 месяцев 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 9, 30), True), ("2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), True), ("Октябрь 2024 г. - Сентябрь 2025 г.", datetime.date(2024, 10, 1), datetime.date(2025, 9, 30), False), ("Апрель - Июнь 2025 г.", datetime.date(2025, 4, 1), datetime.date(2025, 6, 30), False), ("Январь - Май 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 5, 31), True), ("Январь - Февраль 2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 2, 29), True), ("Январь - Октябрь 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 10, 31), True), ("Февраль 2024 г.", datetime.date(2024, 2, 1), datetime.date(2024, 2, 29), False), ], ) def test_period_bounds( rules: Rules, period_text: str, start: datetime.date, end: datetime.date, from_year_start: bool ) -> None: doc = _variant1(rules, title=_title(period_text)) assert doc.period_start is not None assert (doc.period_start, doc.period_end) == (start, end) assert (doc.period_start == datetime.date(doc.period_start.year, 1, 1)) is from_year_start def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None: doc = _variant1(rules, title=_title("за неизвестно что")) assert doc.period_start is None and doc.period_end is None