"""Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк.""" from pathlib import Path import pytest from gridbuilder import L, M, sheet_from_rows from src.contract.result import OsvDocument, OsvRow from src.parser import get_parser from src.parser.deadline import Deadline from src.parser.osv import OsvParser from src.parser.rules import Rules, load_rules TIMEOUT_SECONDS = 60 HEADER_ACCOUNT_CODE = "OSV_HIERARCHY_BY_HEADER_ACCOUNT" NU_ROWS = 3 # две строки НУ под контрагентами и итоговая НУ после «Итого» @pytest.fixture(scope="module") def rules() -> Rules: return load_rules() def _table_head() -> list[list[object]]: return [ ["Субконто", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ] def _parse(rules: Rules, rows: list[list[object]]) -> tuple[OsvDocument, list[str]]: result = OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)) return result.document, [note.code for note in result.notes] def test_flat_list_s40(rules: Rules) -> None: """Образец s40: счёт «по счету: 76.Н.1», вид «Контрагенты», организация ниже периода, данные без оформления.""" rows: list[list[object]] = [ [], ["Оборотно-сальдовая ведомость по счету: 76.Н.1"], ["Контрагенты"], ["за 2025 г."], ['ООО "Товарное хозяйство"'], [], ["Единица измерения: рубль (код по ОКЕИ 383)"], [], *_table_head(), ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], ["АВИС ООО", None, 200, 100, 400, None, 500], ["Агроаспект ООО", None, None, 50, 50, None, None], ["Итого развернутое", 1000, 200, None, None, 1200, 500], ["Итого", 800, None, 650, 750, 700, None], ] document, codes = _parse(rules, rows) assert document.status == "done" assert HEADER_ACCOUNT_CODE in codes data = [item for item in document.rows if item.group_type != "TOTAL"] assert [item.row_value for item in data] == ["Август - Агро ООО", "АВИС ООО", "Агроаспект ООО"] assert all(item.level == 0 and item.parent_row_number is None for item in data) assert {item.account_code_raw for item in data} == {"76.Н.1"} assert document.header.organization_name == 'ООО "Товарное хозяйство"' def test_flat_list_s41(rules: Rules) -> None: """Образец s41 (R-10): схема [ACCOUNT, COUNTERPARTY («Организации»)], счёт шапки «60».""" rows: list[list[object]] = [ [], ["Оборотно-сальдовая ведомость по счету: 60"], ["Организации"], ["за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"], [], *_table_head(), ["Автодор-Платные Дороги ООО", 820, None, 5000, 4991, 829, None], ["АГРОПРОФИ ООО", None, 2764, None, None, None, 2764], ["Итого развернутое", 820, 2764, None, None, 829, 2764], ["Итого", None, 1944, 5000, 4991, None, 1935], ] document, _ = _parse(rules, rows) assert document.status == "done" data = [item for item in document.rows if item.group_type != "TOTAL"] assert [item.account_code_raw for item in data] == ["60", "60"] assert [item.group_type for item in data] == ["COUNTERPARTY", "COUNTERPARTY"] def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None: """Счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки.""" rows: list[list[object]] = [ ["ООО Зерновая"], ["Оборотно-сальдовая ведомость по счету 60"], ["Период: 9 месяцев 2025 г."], [], *_table_head(), [L(0), "ООО А", 100, None, 50, 30, 120, None], [L(1), "Договор 1", 100, None, 50, 30, 120, None], [L(0), "Итого", 100, None, 50, 30, 120, None], ] document, codes = _parse(rules, rows) assert "OSV_HIERARCHY_BY_OUTLINE" in codes assert HEADER_ACCOUNT_CODE not in codes data = [item for item in document.rows if item.group_type != "TOTAL"] assert [item.account_code_raw for item in data] == ["60", "60"] # --- Контрагенты расчётных счетов без названия вида (Task 5, R-6, R-10, R-17) --- BY_ACCOUNT_WARNING = ( "вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте" ) def _parse_synthetic(synthetic_dir: Path, name: str) -> tuple[OsvDocument, list[str], str]: outcome = get_parser().parse((synthetic_dir / name).read_bytes(), "XLSX") assert outcome.error is None, outcome.error (document,) = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] return document, [note.message for note in document.warnings], document.status def _data_rows(document: OsvDocument) -> list[OsvRow]: return [item for item in document.rows if item.group_type != "TOTAL"] @pytest.mark.fixtures def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None: document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx") kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"} assert set(kids) == {"ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"'} assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"} notes = [note for note in document.warnings if note.code == "OSV_ROWS"] assert [note.message for note in notes] == [BY_ACCOUNT_WARNING] assert status == "done" assert messages.count(BY_ACCOUNT_WARNING) == 1 @pytest.mark.fixtures def test_s44_materials_stay_other(synthetic_dir: Path) -> None: document, messages, _ = _parse_synthetic(synthetic_dir, "s44_subkonto_unnamed_10.xlsx") kids = [item for item in _data_rows(document) if item.row_value in {"Мука пшеничная", "Сахар-песок"}] assert [item.row_value for item in kids] == ["Мука пшеничная", "Сахар-песок"] assert {item.group_type for item in kids} == {"OTHER"} assert not any("вид субконто в шапке не назван" in message for message in messages) @pytest.mark.fixtures def test_s41_flat_counterparties(synthetic_dir: Path) -> None: document, messages, _ = _parse_synthetic(synthetic_dir, "s41_flat_chart_of_accounts.xlsx") data = _data_rows(document) assert {item.group_type for item in data} == {"COUNTERPARTY"} assert {item.account_code_raw for item in data} == {"60"} assert not any("вид субконто в шапке не назван" in message for message in messages) def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None: """T4.2: плоский список под счётом шапки с испорченной суммой «Итого» — needs_check, не done.""" rows: list[list[object]] = [ [], ["Оборотно-сальдовая ведомость по счету: 76.Н.1"], ["Контрагенты"], ["за 2025 г."], [], *_table_head(), ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], ["АВИС ООО", None, 200, 100, 400, None, 500], ["Итого", 800, None, 650, 700, 1200, 500], ] document, _ = _parse(rules, rows) assert document.status == "needs_check" assert document.checks is not None assert document.checks.total_row_matches_root is False # T4.1: без корня ACCOUNT текст говорит о сумме строк уровня 0, а не о «корневом счёте нет» messages = [note.message for note in document.warnings] assert any("≠ сумме 2 строк уровня 0" in message for message in messages), messages assert not any("корневого счёта нет" in message for message in messages) def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None: """Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё. Показатели не становятся контрагентами (R-6, R-18), не попадают в тождество строки (R-19) и не сбивают сверку «Итого» (она идёт по основной «Итого» с меткой БУ). """ rows: list[list[object]] = [ [], ["Оборотно-сальдовая ведомость по счету: 76"], ["Контрагенты"], ["за 2025 г."], ['ООО "Тест"'], [], [ "Субконто", "Показатели", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2), ], [None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ["Август - Агро ООО", "БУ", 1000, None, 500, 300, 1200, None], [None, "НУ", 900, None, 400, 300, 1000, None], ["АВИС ООО", "БУ", None, 200, 100, 400, None, 500], [None, "НУ", None, 150, 100, 350, None, 400], ["Итого развернутое", "БУ", 1000, 200, 600, 700, 1200, 500], ["Итого", "БУ", 1000, 200, 600, 700, 1200, 500], [None, "НУ", 900, 150, 500, 650, 1000, 400], ] document, codes = _parse(rules, rows) assert document.status == "done", [note.message for note in document.warnings] assert document.checks is not None assert document.checks.total_row_matches_root is True indicators = [item for item in document.rows if item.data_type == "NU"] assert len(indicators) == NU_ROWS assert {item.counterparty_name for item in indicators} == {None} assert {item.group_type for item in indicators if item.group_type != "TOTAL"} == {"OTHER"} counterparties = [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"] assert counterparties == ["Август - Агро ООО", "АВИС ООО"] assert [item.group_type for item in document.rows if item.group_type == "TOTAL" and item.data_type == "BU"] == [ "TOTAL", "TOTAL", ]