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

315 lines
15 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"
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) и не сбивают сверку «Итого» (она идёт по основной «Итого» с
меткой БУ); тождество строки у них проверяется, как у всех (спека трека A) — строки НУ его соблюдают.
"""
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",
]
def _indicator_flat_sheet(tail: list[list[object]]) -> list[list[object]]:
"""Плоская форма 1С с колонкой «Показатели»: два контрагента с БУ/НУ, дальше — итоговый блок `tail`."""
return [
[],
["Оборотно-сальдовая ведомость по счету: 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],
*tail,
]
def test_indicator_under_second_total_before_total_joins_total_block(rules: Rules) -> None:
"""I-6, R-21: НУ без объединения под «Итого развернутое» перед «Итого» — итоговый блок, не фиктивный контрагент."""
rows = _indicator_flat_sheet(
[
["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500],
[None, "НУ", 900, 150, None, None, 1000, 400],
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
)
document, _ = _parse(rules, rows)
assert document.status == "done", [note.message for note in document.warnings]
assert [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"] == [
"Август - Агро ООО",
"АВИС ООО",
]
secondary = next(item for item in document.rows if item.row_value == "Итого развернутое")
assert secondary.group_type == "TOTAL"
assert document.checks is not None
assert document.checks.balance_identity_violations == []
def test_indicator_under_last_data_row_is_not_pulled_into_total_block(rules: Rules) -> None:
"""R-21, негативный: НУ под последней строкой данных перед «Итого» остаётся показателем этой строки."""
rows = _indicator_flat_sheet(
[
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
)
document, _ = _parse(rules, rows)
nu_under_data = [item for item in document.rows if item.data_type == "NU" and item.group_type != "TOTAL"]
assert len(nu_under_data) == NU_ROWS - 1
assert document.status == "done", [note.message for note in document.warnings]
@pytest.mark.parametrize("secondary_first", [True, False])
def test_indicators_of_second_total_without_turnovers_skip_identity(rules: Rules, secondary_first: bool) -> None:
"""I-7, R-21: «Итого развернутое» без оборотов со своим НУ не даёт нарушений тождества (до и после «Итого»)."""
secondary: list[list[object]] = [
["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500],
[None, "НУ", 900, 150, None, None, 1000, 400],
]
main: list[list[object]] = [
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
document, _ = _parse(rules, _indicator_flat_sheet([*secondary, *main] if secondary_first else [*main, *secondary]))
assert document.status == "done", [note.message for note in document.warnings]
assert document.checks is not None
assert document.checks.balance_identity_violations == []