arm-tabular-parser/api/tests/synthetic/test_synthetic_files.py

802 lines
41 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.

"""Сценарии спеки `osv-parsing` на синтетических файлах `../fixtures/osv/synthetic/` (Срез 2a, Task 11).
Ожидания выведены из сценариев спеки и из содержимого, которое генератор записывает в файл (суммы и тексты ниже
перечислены в тесте явно), а не из текущего вывода парсера: файл — вход, оракулом он не служит. Без каталога fixtures
тесты пропускаются (маркер `fixtures`).
"""
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import cast
import pytest
from result_contract import assert_valid_result
from src.contract import FileType, FileWarning, OsvDocument, ResultStatus
from src.contract.result import OsvRow
from src.parser import ParseOutcome, get_parser
from src.parser.readers.xls import NO_CODEPAGE_WARNING
pytestmark = pytest.mark.fixtures
@pytest.fixture(autouse=True)
def _require_synthetic(synthetic_dir: Path) -> None:
"""T11.1: fixtures есть, а `osv/synthetic` нет/пуст (fixtures на main до слияния ветки) — skip, а не падение."""
SYNTHETIC_DIR = Path("osv") / "synthetic"
INN_LAYOUT_FILES = (
"s20_inn_above_sibling_blank.xlsx",
"s21_inn_above_sibling_sums.xlsx",
"s22_inn_above_parent_blank.xlsx",
"s23_inn_above_parent_sums.xlsx",
"s24_inn_below_child_blank.xlsx",
"s25_inn_below_child_sums.xlsx",
"s26_inn_below_sibling_sums.xlsx",
"s27_inn_below_sibling_blank.xlsx",
)
INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx"
XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls"
VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
# Плоская форма ОСВ 1С (#59)
FLAT_FORM_FILES = (
"s40_flat_header_account.xlsx",
"s41_flat_chart_of_accounts.xlsx",
"s42_subaccounts_totals_reversed.xlsx",
"s43_subkonto_unnamed_60.xlsx",
"s44_subkonto_unnamed_10.xlsx",
)
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
"s02_no_outline_indent.xlsx",
"s03_no_outline_no_indent.xlsx",
"s04_currency_quantity_rows.xlsx",
"s05_text_numbers.xlsx",
"s06_two_osv_and_other_sheet.xlsx",
"s07_no_opening_collapsed_closing.xlsx",
"s08_extra_kpp_column.xlsx",
"s09_subaccount_names.xlsx",
"s10_broken_sum.xlsx",
"s11_header_repeat.xlsx",
"s12_statement_and_osv.xlsx",
"s13_no_outline_indent.xlsb",
"s14_statement_forms.xlsx",
"s15_group_title_synonyms.xlsx",
"s16_header_synonyms.xlsx",
"s17_inn_in_name.xlsx",
"s18_inn_row_above.xlsx",
"s19_inn_kpp_cell.xlsx",
*INN_LAYOUT_FILES,
INN_BAD_CHECKSUM_FILE,
XLS_NO_CODEPAGE_FILE,
VAL_KOL_VO_FILE,
ROWS_BELOW_TOTAL_FILE,
TOTAL_WITH_SUFFIX_FILE,
"s33_indicator_column_bu_nu.xlsx",
"s34_indicator_column_qty_val_diff.xlsx",
"s35_currency_slices.xlsx",
"s36_no_data_type_header.xlsx",
*FLAT_FORM_FILES,
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
ORG = 'ООО "Тестовая организация"'
VARIANT_2 = 2
XLSB_WARNING = "xlsb: outline level недоступен"
ALFA = "ООО Альфа"
BETA = "ООО Бета"
GAMMA = "ООО Гамма"
D = Decimal
@dataclass(frozen=True)
class Expect:
"""Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
hierarchy: str
year: int
quarter: str
cumulative: bool
status: ResultStatus = ResultStatus.DONE
documents: int = 1
variant: int = 1
data_type: str | None = "BU"
organization: str = ORG
# Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех
variants: tuple[int, ...] = ()
EXPECTED: dict[str, Expect] = {
# «Период: 1 квартал 2026 г.» — вариант 2, данные «сумма»
"s01_variant2_header.xlsx": Expect("outline", 2026, "Q1", True, variant=2, data_type="SUM"),
"s02_no_outline_indent.xlsx": Expect("indent", 2025, "Q2", False),
# римский квартал «I кв.»
"s03_no_outline_no_indent.xlsx": Expect("account_pattern", 2025, "Q1", True),
"s04_currency_quantity_rows.xlsx": Expect("outline", 2025, "Q3", False),
# «полугодие»
"s05_text_numbers.xlsx": Expect("outline", 2025, "Q2", True),
"s06_two_osv_and_other_sheet.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
# «9 месяцев»
"s07_no_opening_collapsed_closing.xlsx": Expect("outline", 2025, "Q3", True),
# диапазон дат «с 01.01.2025 по 31.03.2025»
"s08_extra_kpp_column.xlsx": Expect("outline", 2025, "Q1", True),
"s09_subaccount_names.xlsx": Expect("outline", 2025, "Q4", False),
"s10_broken_sum.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
# «12 месяцев»
"s11_header_repeat.xlsx": Expect("outline", 2025, "Q4", True),
# «январь - сентябрь», данные «НУ»
"s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, status=ResultStatus.NEEDS_CHECK, data_type="NU"),
"s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False),
# три листа форм по одному признаку + лист ОСВ
"s14_statement_forms.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
# названия групп сумм: «Входящее/Конечное», «Начальное/Исходящее сальдо»
"s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
# шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2
"s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 2)),
# ИНН в тексте названия, колонки ИНН нет
"s17_inn_in_name.xlsx": Expect("outline", 2025, "Q1", True),
# ИНН строкой над названием
"s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False),
# колонка «ИНН/КПП»
"s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False),
# восемь раскладок строки ИНН (#35)
**{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES},
# ИНН с неверной контрольной суммой: строка остаётся строкой, документ на проверку
INN_BAD_CHECKSUM_FILE: Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
# xls BIFF5 без CODEPAGE, строки cp1251; отступ пробелами
XLS_NO_CODEPAGE_FILE: Expect("indent", 2025, "Q1", True),
# счёт 10, вариант 2: материал «Вал» и строки «Кол-во»
VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2),
# подпись и блок чисел под «Итого»
ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True),
# итог подписан «Итого по счету 60»: точного «Итого» нет
TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True),
"s33_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None),
"s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False),
"s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False),
"s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None),
# плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `header.data_type = None`; строки — BU (#58)
# счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.»
"s40_flat_header_account.xlsx": Expect(
"header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"'
),
# вид «Организации», «за 2 Квартал 2026 г. План счетов …»
"s41_flat_chart_of_accounts.xlsx": Expect(
"header_account", 2026, "Q2", False, data_type=None, organization="ООО Хреновская Нива"
),
# субсчета 76.2…76.Н.1 по шаблону кода; «Итого развернутое» перед «Итого»
"s42_subaccounts_totals_reversed.xlsx": Expect(
"account_pattern", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"'
),
# счёт 60 без вида аналитики в шапке, отступы
"s43_subkonto_unnamed_60.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
# то же на счёте 10: материалы остаются OTHER
"s44_subkonto_unnamed_10.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
}
# --- Помощники ---
def synthetic_path(fixtures_dir: Path, name: str) -> Path:
return fixtures_dir / SYNTHETIC_DIR / name
def parse_path(path: Path) -> ParseOutcome:
return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome:
path = synthetic_path(fixtures_dir, name)
if not path.is_file():
if name.endswith(".xlsb"):
pytest.skip(XLSB_MISSING_REASON)
pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
return parse_path(path)
def documents(outcome: ParseOutcome) -> list[OsvDocument]:
assert outcome.error is None, outcome.error
"""Документы ОСВ результата; документы форм (`StatementDocument`) разбираются отдельными сценариями."""
return [doc for doc in outcome.documents if isinstance(doc, OsvDocument)]
def one_document(outcome: ParseOutcome) -> OsvDocument:
(doc,) = documents(outcome)
return doc
def warnings_of(outcome: ParseOutcome) -> list[str]:
"""Тексты предупреждений документов ОСВ (контракт 2.0: лежат в самих документах), по порядку документов."""
return [w.message for doc in documents(outcome) for w in doc.warnings]
def file_warnings_of(outcome: ParseOutcome) -> list[FileWarning]:
"""Предупреждения файла (`metrics.warnings`)."""
assert outcome.metrics is not None
assert outcome.metrics.warnings is not None
return outcome.metrics.warnings
def by_text(doc: OsvDocument) -> dict[str, OsvRow]:
"""Строки по тексту (в синтетике тексты строк уникальны внутри листа)."""
rows = {row.row_value: row for row in doc.rows}
assert len(rows) == len(doc.rows), "тексты строк синтетики должны быть уникальны"
return rows
def rows_text(doc: OsvDocument) -> list[str]:
return [row.row_value for row in doc.rows]
def sums(row: OsvRow) -> tuple[Decimal | None, ...]:
return (
row.opening_debit,
row.opening_credit,
row.turnover_debit,
row.turnover_credit,
row.closing_debit,
row.closing_credit,
)
def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | None, ...]]]:
"""Дерево документа: текст, уровень, родитель, суммы — для сравнения двух файлов одной ведомости."""
return [(row.row_value, row.level, row.parent_row_number, sums(row)) for row in doc.rows]
# --- Состав набора и общие ожидания ---
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s36, s40…s44 (s13 — xlsb; без него допустимо)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present]
assert missing in ([], [XLSB_NAME]), f"нет файлов набора: {missing}"
def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
"""Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора)."""
assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}"
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
expect = EXPECTED[synthetic_file.name]
outcome = parse_path(synthetic_file)
assert outcome.status is expect.status, outcome.metrics
docs = documents(outcome)
assert len(docs) == expect.documents
hierarchy_code = f"OSV_HIERARCHY_BY_{expect.hierarchy.upper()}"
hierarchy_notes = [w for doc in docs for w in doc.warnings if w.code == hierarchy_code]
assert len(hierarchy_notes) == expect.documents, warnings_of(outcome)
assert all(w.severity == "info" and w.code.startswith("OSV_") for doc in docs for w in doc.warnings)
variants = expect.variants or (expect.variant,) * expect.documents
assert [doc.header.header_variant for doc in docs] == list(variants)
for doc in docs:
assert doc.header.period_year == expect.year
assert doc.header.period_quarter == expect.quarter
assert doc.header.is_cumulative is expect.cumulative
assert doc.header.data_type == expect.data_type
assert doc.header.organization_name == expect.organization
assert doc.rows[-1].group_type == "TOTAL"
if expect.status is ResultStatus.DONE:
assert doc.status == "done"
assert doc.checks is not None
assert doc.checks.total_row_matches_root is True
assert doc.checks.balance_identity_violations == []
assert doc.checks.parent_sum_violations == []
assert_valid_result(outcome)
# --- Сценарии спеки ---
def test_s01_variant2_header_and_subkonto_groups(fixtures_dir: Path) -> None:
"""«Вариант 2»: `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = Q1`."""
doc = one_document(parse_named(fixtures_dir, "s01_variant2_header.xlsx"))
assert doc.header.header_variant == VARIANT_2
assert [group.type for group in doc.group_schema] == ["ACCOUNT", "COUNTERPARTY", "CONTRACT"]
assert doc.header.period_quarter == "Q1"
assert doc.header.period_text == "1 квартал 2026 г."
rows = by_text(doc)
contract = rows["Договор поставки № 15 от 10.01.2026"]
assert contract.group_type == "CONTRACT"
assert contract.parent_row_number == rows[ALFA].row_number
assert rows[ALFA].group_type == "COUNTERPARTY"
assert rows[ALFA].counterparty_inn == "7701234567"
def test_s02_indent_levels_and_tree(fixtures_dir: Path) -> None:
"""«Без outline level»: `hierarchy=indent`, уровни 0/1/2, родитель контрагента — субсчёт."""
doc = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
rows = by_text(doc)
assert [(text, rows[text].level) for text in ("60", "60.01", ALFA, "60.02", GAMMA, "Итого")] == [
("60", 0),
("60.01", 1),
(ALFA, 2),
("60.02", 1),
(GAMMA, 2),
("Итого", 0),
]
assert rows[ALFA].parent_row_number == rows["60.01"].row_number
assert rows["60.01"].parent_row_number == rows["60"].row_number
assert rows["60"].parent_row_number is None
def test_s02_and_s03_describe_the_same_tree(fixtures_dir: Path) -> None:
"""Одна ведомость с отступами (s02) и с кодами счетов и жирностью (s03): дерево и суммы совпадают."""
indent = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
pattern = one_document(parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx"))
assert tree(indent) == tree(pattern)
def test_s03_account_pattern_levels(fixtures_dir: Path) -> None:
"""«Без outline и отступов»: уровни по кодам счетов и жирности, контрольные суммы сходятся (в общем тесте)."""
outcome = parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx")
doc = one_document(outcome)
rows = by_text(doc)
assert [rows[text].level for text in ("60", "60.01", ALFA, BETA, "Иванов Иван", "60.02", GAMMA)] == [
0,
1,
2,
2,
2,
1,
2,
]
assert rows[GAMMA].parent_row_number == rows["60.02"].row_number
assert outcome.status is ResultStatus.DONE
def test_s04_currency_and_quantity_rows(fixtures_dir: Path) -> None:
"""«Строки Вал./Кол.»: USD и Вал. — CURRENCY, Кол. — QUANTITY; в проверку суммы детей не входят, статус `done`."""
outcome = parse_named(fixtures_dir, "s04_currency_quantity_rows.xlsx")
doc = one_document(outcome)
rows = by_text(doc)
assert rows["USD"].data_type == "CURRENCY"
assert rows["Кол."].data_type == "QUANTITY"
assert rows["Вал."].data_type == "CURRENCY"
assert rows["USD"].parent_row_number == rows[ALFA].row_number
assert rows["Вал."].parent_row_number == rows[BETA].row_number
assert rows[ALFA].data_type == "BU"
assert rows[ALFA].group_type == "COUNTERPARTY"
# суммы показателей остаются в шести полях
assert rows["Кол."].opening_debit == D("10.00")
assert rows["USD"].opening_credit == D("2500.00")
assert outcome.status is ResultStatus.DONE
def test_s05_text_numbers(fixtures_dir: Path) -> None:
"""«Текстовые числа»: «1 234 567,89» → 1234567.89, «(1 234,00)» → -1234.00, «—»/«–»/«-» → null."""
doc = one_document(parse_named(fixtures_dir, "s05_text_numbers.xlsx"))
rows = by_text(doc)
assert sums(rows[ALFA]) == (D("1234567.89"), None, D("500.00"), D("-1234.00"), D("1236301.89"), None)
# неразрывные пробелы и «–»
assert sums(rows[BETA]) == (D("15000.00"), None, D("90000.00"), D("40000.00"), D("65000.00"), None)
# узкие неразрывные пробелы и «-»
assert sums(rows[GAMMA]) == (D("120000.00"), None, D("30000.00"), D("100000.00"), D("50000.00"), None)
def test_s06_two_osv_sheets_and_other_sheet(fixtures_dir: Path) -> None:
"""«Два листа ОСВ и посторонний лист»: два документа в порядке листов, в warnings — пропуск листа «Справка»."""
outcome = parse_named(fixtures_dir, "s06_two_osv_and_other_sheet.xlsx")
docs = documents(outcome)
assert [doc.locator for doc in docs] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
assert [doc.header.account_code_raw for doc in docs] == ["60", "62"]
assert [(w.code, w.severity, w.locator) for w in file_warnings_of(outcome)] == [
("SHEET_OTHER", "info", "sheet:Справка")
]
assert outcome.metrics is not None
assert outcome.metrics.rows == sum(1 for doc in docs for row in doc.rows if row.group_type != "TOTAL")
def test_s07_no_opening_and_collapsed_closing(fixtures_dir: Path) -> None:
"""«Без начала и со свёрнутым сальдо»: `opening_* = null`, отрицательное свёрнутое — в `closing_credit`, warning.
T11.2: смешанные знаки у детей (Альфа — кредитовое, Бета — дебетовое) намеренно проверяют правило R-21: свёрнутое
сальдо родителя сверяется по нетто (дебет минус кредит), а не по сумме модулей.
"""
outcome = parse_named(fixtures_dir, "s07_no_opening_collapsed_closing.xlsx")
doc = one_document(outcome)
for row in doc.rows:
assert row.opening_debit is None
assert row.opening_credit is None
rows = by_text(doc)
assert (rows[ALFA].closing_debit, rows[ALFA].closing_credit) == (None, D("250000.50"))
assert (rows[BETA].closing_debit, rows[BETA].closing_credit) == (D("50000.00"), None)
assert (rows[GAMMA].closing_debit, rows[GAMMA].closing_credit) == (None, None)
assert rows[ALFA].turnover_credit == D("350000.50")
assert any("свёрнутое сальдо" in warning for warning in warnings_of(outcome))
assert outcome.status is ResultStatus.DONE
def test_s08_kpp_column_goes_to_extra(fixtures_dir: Path) -> None:
"""«Неизвестная колонка внутри таблицы»: `extra["КПП"]` у контрагентов, суммы не сдвинуты, ИНН в своей колонке."""
doc = one_document(parse_named(fixtures_dir, "s08_extra_kpp_column.xlsx"))
rows = by_text(doc)
assert rows[ALFA].extra == {"КПП": "770101001"}
assert rows[BETA].extra == {"КПП": "770701001"}
assert rows["Иванов Иван"].extra is None
assert rows["60"].extra is None
assert rows[ALFA].counterparty_inn == "7701234567"
assert sums(rows[ALFA]) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
assert doc.columns is not None
assert doc.columns["inn"] == "E"
assert (doc.columns["opening_debit"], doc.columns["closing_credit"]) == ("G", "L")
def test_s09_subaccount_names_in_account_rows(fixtures_dir: Path) -> None:
"""«Название субсчёта в строке счёта»: ACCOUNT, `account_code_raw` без названия, `row_value` — исходный текст."""
doc = one_document(parse_named(fixtures_dir, "s09_subaccount_names.xlsx"))
rows = by_text(doc)
named = rows["60.01, Расчеты с поставщиками и подрядчиками"]
assert (named.group_type, named.account_code_raw) == ("ACCOUNT", "60.01")
assert rows["60.02, Расчеты по авансам выданным"].account_code_raw == "60.02"
# буквенный субсчёт (R-16): счёт, а не контрагент; дети наследуют его код
letter = rows["60.АВ, Расчеты по авансам (буквенный субсчёт)"]
assert (letter.group_type, letter.account_code_raw) == ("ACCOUNT", "60.АВ")
assert rows["ООО Дельта"].account_code_raw == "60.АВ"
assert rows["ООО Дельта"].group_type == "COUNTERPARTY"
assert rows[ALFA].account_code_raw == "60.01"
def test_s10_broken_sum_needs_check(fixtures_dir: Path) -> None:
"""«Испорченная сумма»: `needs_check`, нарушение тождества — у контрагента, суммы родителя — у его субсчёта."""
outcome = parse_named(fixtures_dir, "s10_broken_sum.xlsx")
doc = one_document(outcome)
rows = by_text(doc)
assert outcome.status is ResultStatus.NEEDS_CHECK
assert doc.checks is not None
assert doc.checks.balance_identity_violations == [rows[BETA].row_number]
assert doc.checks.parent_sum_violations == [rows["60.01"].row_number]
assert doc.checks.total_row_matches_root is True
assert rows[BETA].closing_debit == D("65001.00")
texts = [w for w in warnings_of(outcome) if "§1.4" in w]
assert any(str(rows[BETA].row_number) in w for w in texts)
assert any(str(rows["60.01"].row_number) in w for w in texts)
def test_s11_header_repeat_is_skipped(fixtures_dir: Path) -> None:
"""«Повтор заголовка внутри данных» (печать по страницам): строки заголовка не данные, дерево не нарушено."""
outcome = parse_named(fixtures_dir, "s11_header_repeat.xlsx")
doc = one_document(outcome)
assert rows_text(doc) == [
"60",
"60.01",
ALFA,
BETA,
"Иванов Иван",
"60.02",
GAMMA,
"ООО Дельта",
"ИП Сидоров",
"Итого",
]
rows = by_text(doc)
assert [rows[text].level for text in rows_text(doc)] == [0, 1, 2, 2, 2, 1, 2, 2, 2, 0]
assert rows["60.02"].parent_row_number == rows["60"].row_number
assert outcome.status is ResultStatus.DONE
def test_s12_statement_sheet_goes_to_statement_pipeline(fixtures_dir: Path) -> None:
"""«Лист ББ + лист ОСВ»: документ ОСВ и документ ББ; у синтетического ББ нет шапки и колонки кодов → needs_check."""
outcome = parse_named(fixtures_dir, "s12_statement_and_osv.xlsx")
(doc,) = documents(outcome)
assert doc.locator == "sheet:ОСВ 60"
assert [(d.document_type, d.locator) for d in outcome.documents] == [
("OSV", "sheet:ОСВ 60"),
("BALANCE", "sheet:Бухгалтерский баланс"),
]
assert outcome.status is ResultStatus.NEEDS_CHECK
def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> None:
"""«XLSB»: результат совпадает с xlsx-оригиналом (s02), кроме `locator` и предупреждения об outline."""
xlsb = parse_named(fixtures_dir, "s13_no_outline_indent.xlsb")
xlsx = parse_named(fixtures_dir, "s02_no_outline_indent.xlsx")
xlsb_doc, xlsx_doc = one_document(xlsb), one_document(xlsx)
neutral = {"locator": "", "warnings": []}
assert xlsb_doc.model_copy(update=neutral) == xlsx_doc.model_copy(update=neutral)
assert xlsb.status is xlsx.status is ResultStatus.DONE
assert [(w.code, w.message) for w in xlsb_doc.warnings if w.code == "OSV_XLSB_NO_OUTLINE"] == [
("OSV_XLSB_NO_OUTLINE", XLSB_WARNING)
]
assert [w for w in xlsb_doc.warnings if w.code != "OSV_XLSB_NO_OUTLINE"] == xlsx_doc.warnings
assert "OSV_HIERARCHY_BY_INDENT" in {w.code for w in xlsb_doc.warnings}
assert xlsb.metrics is not None
assert xlsx.metrics is not None
assert xlsb.metrics.rows == xlsx.metrics.rows
def test_s14_each_statement_form_is_recognised_by_its_own_marker(fixtures_dir: Path) -> None:
"""Три листа форм (код ОКУД 0710001, «Отчет о финансовых результатах», код 0710002) дают три документа форм."""
outcome = parse_named(fixtures_dir, "s14_statement_forms.xlsx")
(doc,) = documents(outcome)
assert doc.locator == "sheet:ОСВ 60"
statements = [(d.document_type, d.locator) for d in outcome.documents if d.document_type != "OSV"]
assert statements == [("BALANCE", "sheet:Форма 1"), ("INCOME", "sheet:ОФР"), ("INCOME", "sheet:Форма 2")]
assert outcome.status is ResultStatus.NEEDS_CHECK
def test_s15_group_title_synonyms_find_all_six_sum_columns(fixtures_dir: Path) -> None:
"""«Входящее/Конечное сальдо» (лист 60), «Начальное/Исходящее сальдо» (лист 62): шесть колонок, суммы верны."""
outcome = parse_named(fixtures_dir, "s15_group_title_synonyms.xlsx")
first, second = documents(outcome)
assert (first.locator, second.locator) == ("sheet:ОСВ 60", "sheet:ОСВ 62")
alfa = by_text(first)[ALFA]
assert sums(alfa) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
buyer = by_text(second)["ООО Покупатель-1"]
assert sums(buyer) == (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None)
assert outcome.status is ResultStatus.DONE
def test_s16_header_without_organization_year_only_period_and_currency_note(fixtures_dir: Path) -> None:
"""Шапка без организации (предупреждение), период «2024» -> Q4, валюта «RUB (код 643)» -> RUB, метки группировок
отдельными строками («Счет» среди них), «Детализация: …» без слова «субконто» и группировки вне ключевых."""
outcome = parse_named(fixtures_dir, "s16_header_synonyms.xlsx")
first, second = documents(outcome)
assert first.header.organization_name == ""
assert any("организации в шапке не найдено" in w for w in warnings_of(outcome))
assert (first.header.period_text, first.header.period_year, first.header.period_quarter) == ("2024", 2024, "Q4")
assert first.header.currency == "RUB"
assert [group.type for group in first.group_schema] == ["ACCOUNT", "COUNTERPARTY"]
assert second.header.header_variant == VARIANT_2
assert [(group.type, group.name) for group in second.group_schema] == [
("ACCOUNT", "Субконто"),
("OTHER", "Номенклатура"),
("OTHER", "Статьи затрат"),
("OTHER", "Нематериальные активы"),
("OTHER", "Валюта"),
]
assert outcome.status is ResultStatus.DONE
def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> None:
"""OQ-100: ИНН из текста названия («, », «(ИНН …)», «ИНН: …», пробел) — в `counterparty_inn`, имя без ИНН."""
doc = one_document(parse_named(fixtures_dir, "s17_inn_in_name.xlsx"))
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {
ALFA: "7701234567",
BETA: "7707654321",
GAMMA: "5001234567",
"ИП Сидоров": "500100732259",
"Иванов Иван": None,
}
alfa = next(row for row in doc.rows if row.counterparty_name == ALFA)
assert alfa.row_value == "ООО Альфа, 7701234567" # исходный текст не меняется
assert doc.columns is not None
assert "inn" not in doc.columns
def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None:
"""OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента."""
outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx")
doc = one_document(outcome)
assert not [row for row in doc.rows if row.group_type == "OTHER"]
assert not [row for row in doc.rows if row.row_value in {"7701234560", "5001234561"}]
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"}
assert any("ИНН 7701234560" in warning for warning in warnings_of(outcome))
INN_LAYOUT_COUNTERPARTIES = {
ALFA: "7701234560",
BETA: "7707654321",
"Иванов Иван": None,
GAMMA: "5001234561",
"ИП Сидоров": "500100732259",
}
@pytest.mark.parametrize("name", INN_LAYOUT_FILES)
def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: str) -> None:
"""#35: в каждой из восьми раскладок строки ИНН у каждого контрагента — ИНН его строки, строк ИНН в `rows` нет."""
outcome = parse_named(fixtures_dir, name)
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE, warnings_of(outcome)
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == INN_LAYOUT_COUNTERPARTIES
assert not [row for row in doc.rows if row.group_type == "OTHER"]
inns = {inn for inn in INN_LAYOUT_COUNTERPARTIES.values() if inn}
assert not [row for row in doc.rows if row.row_value in inns]
def test_s28_bad_checksum_inn_row_stays_a_row_and_needs_check(fixtures_dir: Path) -> None:
"""Опечатка в ИНН ООО Альфа: «7701234567» остаётся обычной строкой, у Альфы ИНН нет, предупреждение, проверка;
остальные контрагенты получают свои ИНН (направление «над» — по блоку 60.02)."""
outcome = parse_named(fixtures_dir, INN_BAD_CHECKSUM_FILE)
doc = one_document(outcome)
assert outcome.status is ResultStatus.NEEDS_CHECK
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties.pop("7701234567") is None # обычная строка: классификатор принял её за контрагента
assert counterparties == {**INN_LAYOUT_COUNTERPARTIES, ALFA: None}
assert any(
"похожа на ИНН 7701234567" in warning and "контрольная сумма не сходится" in warning
for warning in warnings_of(outcome)
)
# решение 20: предупреждение названо для строки 13, строка остаётся COUNTERPARTY без ИНН, в OTHER не уходит
assert any(f"строка {TYPO_ROW}: похожа на ИНН 7701234567" in warning for warning in warnings_of(outcome))
typo_rows = [row for row in doc.rows if row.row_number == TYPO_ROW]
assert [(row.row_value, row.group_type, row.counterparty_inn) for row in typo_rows] == [
("7701234567", "COUNTERPARTY", None)
]
assert not [row for row in doc.rows if row.group_type == "OTHER"]
def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None:
"""«ИНН/КПП» в одной ячейке: в `counterparty_inn` — только ИНН; колонка распознана как колонка ИНН."""
doc = one_document(parse_named(fixtures_dir, "s19_inn_kpp_cell.xlsx"))
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {
ALFA: "7701234567",
BETA: "7707654321",
"Иванов Иван": None,
GAMMA: "5001234567",
"ООО Дельта": "7812345678",
}
assert doc.columns is not None
assert doc.columns["inn"] == "E"
def test_s29_xls_without_codepage_is_read_as_cp1251(fixtures_dir: Path) -> None:
"""AURORA-1662: в книге BIFF5 нет CODEPAGE — кириллица читается как cp1251, предупреждение об этом есть."""
outcome = parse_named(fixtures_dir, XLS_NO_CODEPAGE_FILE)
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE
assert doc.header.organization_name == ORG
assert {ALFA, BETA, GAMMA} <= set(rows_text(doc))
assert [(w.code, w.severity, w.locator, w.message) for w in file_warnings_of(outcome)] == [
("XLS_NO_CODEPAGE", "info", None, NO_CODEPAGE_WARNING)
]
assert "OSV_XLS_NO_CODEPAGE" not in {w.code for w in doc.warnings}
def test_s30_val_without_dot_is_a_material_kol_vo_is_quantity(fixtures_dir: Path) -> None:
"""AURORA-1663: «Вал» без точки — номенклатура (не CURRENCY), «Кол-во» — QUANTITY."""
doc = one_document(parse_named(fixtures_dir, VAL_KOL_VO_FILE))
(shaft,) = [row for row in doc.rows if row.row_value == "Вал"]
assert shaft.data_type == doc.header.data_type
assert shaft.group_type == "OTHER"
quantities = [row for row in doc.rows if row.row_value == "Кол-во"]
assert [row.data_type for row in quantities] == ["QUANTITY", "QUANTITY"]
assert not [row for row in doc.rows if row.data_type == "CURRENCY"]
def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> None:
"""AURORA-1664: таблица кончается на первой «Итого»; подпись и блок с числами ниже — не строки, а предупреждение."""
outcome = parse_named(fixtures_dir, ROWS_BELOW_TOTAL_FILE)
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE
assert rows_text(doc)[-1] == "Итого"
assert [row.group_type for row in doc.rows].count("TOTAL") == 1
assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc))
assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome)
def test_s42_secondary_total_before_main_total(fixtures_dir: Path) -> None:
"""#59: «Итого развернутое» перед «Итого» — обе TOTAL, сверка по основной «Итого», статус done."""
outcome = parse_named(fixtures_dir, "s42_subaccounts_totals_reversed.xlsx")
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE
assert doc.status == "done"
assert [row.group_type for row in doc.rows[-2:]] == ["TOTAL", "TOTAL"]
assert doc.checks is not None
assert doc.checks.total_row_matches_root is True
assert doc.checks.parent_sum_violations == []
assert doc.checks.balance_identity_violations == []
def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
"""I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»)."""
doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE))
assert doc.rows[-1].row_value == "Итого по счету 60"
assert doc.rows[-1].group_type == "TOTAL"
def test_s33_indicator_column_bu_nu(fixtures_dir: Path) -> None:
"""AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся."""
outcome = parse_named(fixtures_dir, "s33_indicator_column_bu_nu.xlsx")
doc = one_document(outcome)
by_row = {row.row_number: row for row in doc.rows}
nu = [row for row in doc.rows if row.data_type == "NU"]
assert [row.row_value for row in nu] == ["НУ"] * 4
assert all(row.group_type in {"OTHER", "TOTAL"} for row in nu)
for row in nu:
if row.group_type != "TOTAL":
parent = by_row[row.parent_row_number]
assert parent.data_type == "BU" and parent.row_number == row.row_number - 1
assert row.level == parent.level + 1
assert [row.data_type for row in doc.rows if row.group_type == "TOTAL"] == ["BU", "NU"]
assert {row.data_type for row in doc.rows if row.row_value != "НУ"} == {"BU"}
assert doc.header.data_type is None
texts = warnings_of(outcome)
assert not any("ниже таблицы" in text for text in texts), texts
assert not any("приняты как БУ" in text for text in texts), texts
assert outcome.status is ResultStatus.DONE
def test_s34_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None:
"""«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке."""
outcome = parse_named(fixtures_dir, "s34_indicator_column_qty_val_diff.xlsx")
doc = one_document(outcome)
types = {row.row_value: row.data_type for row in doc.rows if row.row_value in {"Вал.", "Кол.", "ПР", "ВР", "Ед."}}
assert types == {"Вал.": "CURRENCY", "Кол.": "QUANTITY", "ПР": None, "ВР": None, "Ед.": None}
unknown = [text for text in warnings_of(outcome) if "«Ед.»" in text]
assert len(unknown) == 1, warnings_of(outcome)
assert outcome.status is ResultStatus.DONE
def test_s35_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None:
"""AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях."""
outcome = parse_named(fixtures_dir, "s35_currency_slices.xlsx")
doc = one_document(outcome)
by_row = {row.row_number: row for row in doc.rows}
in_currency = [row for row in doc.rows if row.row_value == "В валюте"]
assert [row.data_type for row in in_currency] == ["CURRENCY", "CURRENCY"]
assert [by_row[row.parent_row_number].row_value for row in in_currency] == ["Валюта USD", "Валюта EUR"]
slices = [row for row in doc.rows if row.row_value.startswith("Валюта ")]
assert {(row.group_type, row.data_type) for row in slices} == {("OTHER", "BU")}
assert outcome.status is ResultStatus.DONE
def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
"""Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null."""
outcome = parse_named(fixtures_dir, "s36_no_data_type_header.xlsx")
doc = one_document(outcome)
assert doc.header.data_type is None
assert {row.data_type for row in doc.rows} == {"BU"}
assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1
assert outcome.status is ResultStatus.DONE