783 lines
40 KiB
Python
783 lines
40 KiB
Python
"""Сценарии спеки `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): на этапе снимков текущего поведения ожиданий нет — их добавляют задачи 2–5 плана
|
||
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),
|
||
}
|
||
|
||
|
||
# --- Помощники ---
|
||
|
||
|
||
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, *FLAT_FORM_FILES}, f"добавьте ожидания для {synthetic_file.name}"
|
||
|
||
|
||
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
|
||
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
|
||
if synthetic_file.name in FLAT_FORM_FILES:
|
||
pytest.skip("плоская форма (#59): ожидания по файлу — в сценариях задач 2–5, до них поведение фиксирует golden")
|
||
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
|