diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 2a5f9d0..7893503 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -3,7 +3,8 @@ Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», -строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md` +строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты +другим треком); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -861,6 +862,116 @@ def _s32() -> list[SheetSpec]: return [sheet] +FlatRow = tuple[str, tuple[str | None, ...], int] + + +def _flat_sheet(texts: dict[int, str], group_row: int, rows: Sequence[FlatRow]) -> SheetSpec: + """Плоская форма 1С (#59): колонка A — текст, B…G — суммы «начало / обороты / конец» × «Дебет | Кредит»; заголовок + «Субконто» + группы (объединения B:C, D:E, F:G) в `group_row`, подзаголовки под ним, данные сразу ниже. `texts` — + строки шапки над таблицей; в `rows` пустая сумма (None) — нет ячейки, отступ — выравнивание ячейки.""" + sheet = SheetSpec(SHEET) + for row, text in texts.items(): + sheet.put(row, 1, text) + sheet.put(group_row, 1, "Субконто") + for index, key in enumerate(("opening", "turnover", "closing")): + col = 2 + index * PAIR_WIDTH + sheet.put(group_row, col, GROUP_TITLES[key]) + sheet.merge(group_row, col, group_row, col + 1) + sheet.put(group_row + 1, col, DEBIT) + sheet.put(group_row + 1, col + 1, CREDIT) + for offset, (text, sums, indent) in enumerate(rows): + row = group_row + 2 + offset + sheet.put(row, 1, text, indent=indent) + for col, value in enumerate(sums, start=2): + if value is not None: + sheet.put(row, col, D(value)) + return sheet + + +def _s40() -> list[SheetSpec]: + """Плоская форма (#59): счёт в заголовке «по счету: 76.Н.1» и вид аналитики «Контрагенты» строкой ниже, данные без + outline и отступов; «Итого развернутое» — сумма строк, «Итого» — свёрнутое сальдо.""" + texts = { + 2: "Оборотно-сальдовая ведомость по счету: 76.Н.1", + 3: "Контрагенты", + 4: "за 2025 г.", + 5: 'ООО "Товарное хозяйство"', + 7: "Единица измерения: рубль (код по ОКЕИ 383)", + } + rows: list[FlatRow] = [ + ("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0), + ("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0), + ("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0), + ("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0), + ("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0), + ] + return [_flat_sheet(texts, 9, rows)] + + +def _s41() -> list[SheetSpec]: + """Плоская форма (#59): вид аналитики «Организации», период «за 2 Квартал 2026 г.» со словами «План счетов …».""" + texts = { + 2: "Оборотно-сальдовая ведомость по счету: 60", + 3: "Организации", + 4: "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива", + } + rows: list[FlatRow] = [ + ("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0), + ("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0), + ("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0), + ("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0), + ] + return [_flat_sheet(texts, 5, rows)] + + +def _s42() -> list[SheetSpec]: + """Плоская форма (#59): под счётом 76 идут субсчета «76.2», «76.5», «76.Н», «76.Н.1»; «Итого развернутое» перед + «Итого».""" + texts = { + 2: "Оборотно-сальдовая ведомость по счету: 76", + 3: "за 2025 г.", + 4: 'ООО "Товарное хозяйство"', + 6: "Единица измерения: рубль (код по ОКЕИ 383)", + } + rows: list[FlatRow] = [ + ("76.2", ("1000.00", None, None, "1000.00", None, None), 0), + ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), + ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), + ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), + ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), + ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), + ] + return [_flat_sheet(texts, 8, rows)] + + +def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]: + """Счёт с отступами: корень-субсчёт (без отступа) и два элемента аналитики с отступом 1, без вида аналитики в шапке; + «Итого» перед «Итого развернутое».""" + texts = { + 1: 'ООО "Октябрьское"', + 2: f"Оборотно-сальдовая ведомость по счету {account}", + 3: "Период: 9 месяцев 2024 г.", + } + rows: list[FlatRow] = [ + (root, ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), + (kids[0], ("34618000.00", None, None, "34618000.00", None, None), 1), + (kids[1], (None, None, "4236500.00", "4236500.00", None, None), 1), + ("Итого", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), + ("Итого развернутое", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), + ] + return [_flat_sheet(texts, 5, rows)] + + +def _s43() -> list[SheetSpec]: + """Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом).""" + return _s43_s44("60", "60.01", ("ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"')) + + +def _s44() -> list[SheetSpec]: + """То же, что s43, но счёт 10: номенклатура вместо контрагентов.""" + return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок")) + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -969,6 +1080,11 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "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), + "s40_flat_header_account.xlsx": (_s40, to_xlsx), + "s41_flat_chart_of_accounts.xlsx": (_s41, to_xlsx), + "s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx), + "s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx), + "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -987,7 +1103,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…s36 (xlsx, один xlsb и один xls) в указанный каталог.", + description="Синтетические ОСВ s01…s36, s40…s44 (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_generate.py b/api/tests/synthetic/test_generate.py index 3368500..c86e98d 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -31,7 +31,9 @@ def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None: assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) - assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)] + # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 + numbers = [*range(1, 37), *range(40, 45)] + assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers] assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"] assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index a808005..17bbd70 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -42,6 +42,15 @@ 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", @@ -73,6 +82,7 @@ EXPECTED_NAMES = [ "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)" @@ -229,7 +239,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…s36 (s13 — xlsb; допустима деградация: нет только его).""" + """В `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] @@ -238,11 +248,13 @@ def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: """Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора).""" - assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" + 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)