test(osv): синтетика s33–s36 — колонка «Показатели», срезы валют, шапка без типа данных (#58)

Сценарии намеренно красные: воспроизводят баги AURORA-1613 и AURORA-1661.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 11:40:23 +04:00
parent 74ba89c417
commit af70c4c466
2 changed files with 194 additions and 3 deletions

View File

@ -425,6 +425,67 @@ def form_sheet(name: str, *lines: str) -> SheetSpec:
return sheet return sheet
MERGED = "<объединено>" # ячейка текста — продолжение объединения строки выше
INDICATOR_TITLE = "Показа-\nтели"
@dataclass(frozen=True)
class GridRow:
"""Строка листа «по сетке»: текст (None — пусто, MERGED — объединение с ячейкой выше), метка колонки «Показатели»,
шесть сумм (начало Д/К, обороты Д/К, конец Д/К; строки — `Decimal` в файле) и outline level."""
text: str | None
sums: tuple[str | None, str | None, str | None, str | None, str | None, str | None]
label: str | None = None
outline: int = 0
def _grid_osv( # noqa: PLR0913 - лист описывается полностью одним вызовом
*,
account: str,
group_label: str,
rows: Sequence[GridRow],
data_type_line: str | None,
indicator_column: bool,
name: str = "Лист_1",
) -> SheetSpec:
"""ОСВ, заданная строками напрямую (не деревом `Node`): колонка «Показатели», объединённые названия, соседи."""
sheet = SheetSpec(name)
sheet.put(1, 1, ORG)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {account}")
sheet.put(3, 1, "Период: 3 квартал 2025 г.")
if data_type_line is not None:
sheet.put(4, 1, data_type_line)
sheet.put(5, 1, "Параметры:")
sheet.put(5, 3, "Валюта БУ, НУ: Руб")
label_col, first_sum = 4, 5
top = 7
sheet.put(top, 1, "Счет")
if indicator_column:
sheet.put(top, label_col, INDICATOR_TITLE)
sheet.merge(top, label_col, top + 1, label_col)
for index, key in enumerate(("opening", "turnover", "closing")):
col = first_sum + 2 * index
sheet.put(top, col, GROUP_TITLES[key])
sheet.merge(top, col, top, col + 1)
sheet.put(top + 1, col, DEBIT)
sheet.put(top + 1, col + 1, CREDIT)
sheet.put(top + 1, 1, group_label)
for row, item in enumerate(rows, start=top + 2):
if item.text == MERGED:
sheet.merge(row - 1, 1, row, 1)
elif item.text:
sheet.put(row, 1, item.text)
if item.label is not None:
sheet.put(row, label_col, item.label)
if item.outline:
sheet.outline[row] = item.outline
for offset, value in enumerate(item.sums):
if value is not None:
sheet.put(row, first_sum + offset, D(value))
return sheet
# --- Файлы --- # --- Файлы ---
@ -612,6 +673,68 @@ def _s16() -> list[SheetSpec]:
return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")] return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
def _s33() -> list[SheetSpec]:
"""Колонка «Показатели» БУ/НУ (AURORA-1613): счёт 02, шапка без «Выводимые данные», название объединено на строки
БУ и НУ, «Итого» по БУ и по НУ. Строки НУ — показатели строки БУ над ними."""
rows = [
GridRow("02", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
GridRow(None, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
GridRow("Печь подовая", (None, "100000.00", "20000.00", "10000.00", None, "90000.00"), "БУ", 1),
GridRow(MERGED, (None, "100000.02", "20000.00", "10000.01", None, "90000.03"), "НУ", 1),
GridRow("Плита индукционная", (None, "200000.00", None, "20000.00", None, "220000.00"), "БУ", 1),
GridRow(None, (None, "200000.00", None, "20000.02", None, "220000.02"), "НУ", 1),
GridRow("Итого", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
GridRow(MERGED, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
]
return [
_grid_osv(account="02", group_label="Основные средства", rows=rows, data_type_line=None, indicator_column=True)
]
def _s34() -> list[SheetSpec]:
"""Колонка «Показатели»: «Вал.», «Кол.», «ПР», «ВР» и неизвестная метка «Ед.» — показатели вне сверки родителя."""
rows = [
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), "БУ", 1),
GridRow(None, ("12.50", None, "6.00", "3.00", "15.50", None), "Вал.", 1),
GridRow(None, ("3", None, None, None, "3", None), "Кол.", 1),
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), "БУ", 1),
GridRow(None, (None, None, "10.00", None, "10.00", None), "ПР", 1),
GridRow(None, (None, "5.00", None, None, None, "5.00"), "ВР", 1),
GridRow(None, ("1", None, None, None, "1", None), "Ед.", 1),
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
]
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=True)]
def _s35() -> list[SheetSpec]:
"""«Валюта <код>» — строка в рублях, «В валюте» — показатель (AURORA-1661): пары на одном outline-уровне."""
rows = [
GridRow("60", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
GridRow("60.21", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=1),
GridRow("ООО Альфа", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=2),
GridRow("Валюта USD", ("9000.00", None, "1000.00", None, "10000.00", None), outline=3),
GridRow("В валюте", ("100.00", None, "11.00", None, "111.00", None), outline=3),
GridRow("Валюта EUR", (None, "2000.00", None, "500.00", None, "2500.00"), outline=3),
GridRow("В валюте", (None, "20.00", None, "5.00", None, "25.00"), outline=3),
GridRow("Итого", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
]
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=False)]
def _s36() -> list[SheetSpec]:
"""Шапка без «Выводимые данные», колонки «Показатели» нет: строки — BU по умолчанию с предупреждением."""
rows = [
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), outline=1),
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), outline=1),
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
]
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=None, indicator_column=False)]
# --- Запись файлов --- # --- Запись файлов ---
@ -842,6 +965,10 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx), "s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx),
"s31_rows_below_total.xlsx": (_s31, to_xlsx), "s31_rows_below_total.xlsx": (_s31, to_xlsx),
"s32_total_with_suffix.xlsx": (_s32, to_xlsx), "s32_total_with_suffix.xlsx": (_s32, to_xlsx),
"s33_indicator_column_bu_nu.xlsx": (_s33, to_xlsx),
"s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx),
"s35_currency_slices.xlsx": (_s35, to_xlsx),
"s36_no_data_type_header.xlsx": (_s36, to_xlsx),
} }
FILE_NAMES = tuple(FILES) FILE_NAMES = tuple(FILES)
@ -860,7 +987,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate", prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s32 (xlsx, один xlsb и один xls) в указанный каталог.", description="Синтетические ОСВ s01…s36 (xlsx, один xlsb и один xls) в указанный каталог.",
) )
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv) args = parser.parse_args(argv)

View File

@ -69,6 +69,10 @@ EXPECTED_NAMES = [
VAL_KOL_VO_FILE, VAL_KOL_VO_FILE,
ROWS_BELOW_TOTAL_FILE, ROWS_BELOW_TOTAL_FILE,
TOTAL_WITH_SUFFIX_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",
] ]
XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -93,7 +97,7 @@ class Expect:
status: ResultStatus = ResultStatus.DONE status: ResultStatus = ResultStatus.DONE
documents: int = 1 documents: int = 1
variant: int = 1 variant: int = 1
data_type: str = "BU" data_type: str | None = "BU"
organization: str = ORG organization: str = ORG
# Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех # Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех
variants: tuple[int, ...] = () variants: tuple[int, ...] = ()
@ -144,6 +148,10 @@ EXPECTED: dict[str, Expect] = {
ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True),
# итог подписан «Итого по счету 60»: точного «Итого» нет # итог подписан «Итого по счету 60»: точного «Итого» нет
TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True), 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),
} }
@ -221,7 +229,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s32 (s13 — xlsb; допустима деградация: нет только его).""" """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36 (s13 — xlsb; допустима деградация: нет только его)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) 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] assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present] missing = [name for name in EXPECTED_NAMES if name not in present]
@ -690,3 +698,59 @@ def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
assert doc.rows[-1].row_value == "Итого по счету 60" assert doc.rows[-1].row_value == "Итого по счету 60"
assert doc.rows[-1].group_type == "TOTAL" 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