231 lines
11 KiB
Python
231 lines
11 KiB
Python
"""Плоская форма ОСВ 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",
|
||
]
|