arm-tabular-parser/api/tests/unit/parser/test_osv_flat_form.py

183 lines
8.5 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.

"""Плоская форма ОСВ 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"
@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:
"""Review Focus 4: счёт в шапке, строк-счетов нет, 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-9, R-10) ---
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)