arm-tabular-parser/api/tests/synthetic/test_synthetic_files.py
2026-10-18 11:50:39 +04:00

667 lines
34 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, 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"
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,
]
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 = "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),
}
# --- Помощники ---
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]:
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…s31 (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_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w]
assert len(hierarchy_lines) == expect.documents, warnings_of(outcome)
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.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 any("Справка" in warning and "skipped" in warning for warning in warnings_of(outcome))
assert not any("ОСВ 60" in w and "skipped" in w or "ОСВ 62" in w and "skipped" in w for w in warnings_of(outcome))
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 not any("skipped: statement" in w for w in warnings_of(outcome))
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)
assert xlsb_doc.model_copy(update={"locator": ""}) == xlsx_doc.model_copy(update={"locator": ""})
assert xlsb.status is xlsx.status is ResultStatus.DONE
assert XLSB_WARNING in warnings_of(xlsb)
assert [w for w in warnings_of(xlsb) if w != XLSB_WARNING] == warnings_of(xlsx)
assert any("hierarchy=indent" in w for w in warnings_of(xlsb))
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 not any("skipped: statement" in w for w in warnings_of(outcome))
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 NO_CODEPAGE_WARNING in warnings_of(outcome)
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)