diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index cb415b2..2a5f9d0 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -425,6 +425,67 @@ def form_sheet(name: str, *lines: str) -> SheetSpec: 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")] +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), "s31_rows_below_total.xlsx": (_s31, 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) @@ -860,7 +987,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( 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") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index c398702..a808005 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -69,6 +69,10 @@ EXPECTED_NAMES = [ 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", ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -93,7 +97,7 @@ class Expect: status: ResultStatus = ResultStatus.DONE documents: int = 1 variant: int = 1 - data_type: str = "BU" + data_type: str | None = "BU" organization: str = ORG # Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех variants: tuple[int, ...] = () @@ -144,6 +148,10 @@ EXPECTED: dict[str, Expect] = { 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), } @@ -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: - """В `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()) assert present == [name for name in EXPECTED_NAMES if name 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].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