From 4d8f2429905e7408b3e60b31ff61466f42176ddd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:32:29 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B8=20=D0=9E?= =?UTF-8?q?=D0=A1=D0=92=20=D0=B4=D1=80=D1=83=D0=B3=D0=B8=D1=85=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Генератор: название ведомости, безымянный лист, группы со стороной в ячейке, подпись итога. Позитивы s48–s54 (xfail до словарей и запасного признака), негативы s55–s57. Co-Authored-By: Claude Opus 5.5 --- api/tests/synthetic/generate.py | 267 +++++++++++++++++++- api/tests/synthetic/test_generate.py | 12 +- api/tests/synthetic/test_synthetic_files.py | 166 +++++++++++- 3 files changed, 433 insertions(+), 12 deletions(-) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index ae25f81..acf2361 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -6,6 +6,8 @@ строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78); +s48…s57 — ОСВ других учётных систем (#81): позитивы s48…s54 (названия, группы и стороны, Navision без названия, xls и +xlsb), негативы s55…s57 (почти-ОСВ, которые обязаны остаться прочими листами или формой); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -214,6 +216,15 @@ class OsvSpec: group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES` inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная) labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм + # Название ведомости вместо «Оборотно-сальдовая ведомость по счету … за …» (вариант 1) и его колонка (#81) + title: str | None = None + title_col: int = 1 + untitled: bool = False # нет организации, названия и параметров: заголовок таблицы с `1 + top_padding` (#81) + top_padding: int = 0 + # Группы, подписанные двумя ячейками со стороной («НачалоДт» | «НачалоКт») вместо названия над «Дебет/Кредит»; + # разделены все группы — строки подзаголовков нет (#81) + split_groups: dict[str, tuple[str, str]] = field(default_factory=dict) + total_text: str | None = None # подпись итоговой строки; None — «Итого» GROUP_TITLES = { @@ -247,27 +258,40 @@ def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_labe """ titles = spec.group_titles or GROUP_TITLES below = spec.labels_below and bool(label) + groups = _groups(spec) + one_row = all(key in spec.split_groups for key, _, _ in groups) # заголовок в одну строку, без «Дебет/Кредит» label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП sheet.put(label_row, 1, label) - sheet.put(label_row + 1, 1, sub_label) - service_row = label_row + 1 if below else top + 1 + if not one_row: + sheet.put(label_row + 1, 1, sub_label) + service_row = label_row + 1 if below else (top if one_row else top + 1) if spec.inn_col is not None: sheet.put(service_row, spec.inn_col, spec.inn_header) if spec.kpp_col is not None: sheet.put(service_row, spec.kpp_col, "КПП") - for key, col, width in _groups(spec): + for key, col, width in groups: + if key in spec.split_groups: + debit, credit = spec.split_groups[key] + sheet.put(top, col, debit) + sheet.put(top, col + 1, credit) + continue sheet.put(top, col, titles[key]) sheet.merge(top, col, top, col + width - 1) if width == PAIR_WIDTH: sheet.put(top + 1, col, DEBIT) sheet.put(top + 1, col + 1, CREDIT) - return top + 4 if below else top + 2 + if below: + return top + 4 + return top + 1 if one_row else top + 2 def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int: + if spec.untitled: + return _header_rows(sheet, spec, 1 + spec.top_padding, "Счет", "Контрагенты") if spec.org_name is not None: sheet.put(1, 1, spec.org_name) - sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}") + title = spec.title or f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}" + sheet.put(2, spec.title_col, title) sheet.put(4, 1, spec.data_type_line) sheet.put(7, 1, "Параметры:") sheet.put(7, 3, spec.currency_line) @@ -375,7 +399,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec: _row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank) next_row += 1 total = SheetSpec("") - _row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six()) + _row_cells(total, spec, 1, (Node(spec.total_text or TOTAL_TEXT), 0), root.six()) for (_, col), cell in total.cells.items(): sheet.cells[(next_row, col)] = CellSpec(cell.value, bold=spec.bold_accounts) return sheet @@ -418,6 +442,16 @@ def statement_sheet(name: str = "Бухгалтерский баланс") -> Sh return sheet +def table_sheet(name: str, rows: Sequence[Sequence[str | int | D | None]], *, start_row: int = 1) -> SheetSpec: + """Лист «как есть»: строки ячеек с `start_row`, `None` — пустая ячейка (листы-негативы #81).""" + sheet = SheetSpec(name) + for row, values in enumerate(rows, start=start_row): + for col, value in enumerate(values, start=1): + if value is not None: + sheet.put(row, col, value) + return sheet + + def form_sheet(name: str, *lines: str) -> SheetSpec: """Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности).""" sheet = SheetSpec(name) @@ -1119,6 +1153,215 @@ def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec return build +# --- ОСВ других учётных систем (#81): контрольный набор. s48–s54 — позитивы, s55–s57 — негативы --- + +PERIOD_TAIL = "по счету 60 за 1 квартал 2025 г." +NAVISION_GROUPS = { + "opening": ("НачалоДт", "НачалоКт"), + "turnover": ("ОборотДт", "ОборотКт"), + "closing": ("КонецДт", "КонецКт"), +} + + +def _titled(name: str, title: str, *, title_col: int = 1, total_text: str | None = None) -> SheetSpec: + spec = OsvSpec(inn_col=5, first_sum_col=7, title=title, title_col=title_col, total_text=total_text) + return build_osv(spec, base_tree(), name) + + +def _s48() -> list[SheetSpec]: + """Названия ведомостей других систем и отчётов 1С (AURORA-1717) и итог «ВСЕГО» (AURORA-1732): по листу на название; + название — в разных колонках, верхним регистром с переносом строки.""" + return [ + _titled("ОВ", f"Оборотная ведомость {PERIOD_TAIL}", title_col=3), + _titled("ОСВ сокр", "ОСВ по сч. 60 за 1 квартал 2025 г."), + _titled("Оборотный баланс", f"Оборотный баланс {PERIOD_TAIL}", title_col=7), + _titled("Навижн", "Фин. Обороты Листок за 1 квартал 2025 г.", title_col=4), + _titled("Главная книга", f"Отчет по Главной книге {PERIOD_TAIL}", title_col=3, total_text="ВСЕГО"), + _titled("Верхний регистр", f"ОБОРОТНАЯ\nВЕДОМОСТЬ {PERIOD_TAIL}"), + _titled("Вторая колонка", f"Оборотная ведомость по счетам {PERIOD_TAIL}", title_col=2, total_text="Всего "), + ] + + +def _s49_sheets(hierarchy: Hierarchy = "outline") -> list[SheetSpec]: + """«Остаток на начало/конец» над «Дебет/Кредит» и «Оборот по дебету/кредиту» в ячейке группы (КТЗ); преамбула + «Остаток на начало: …» над таблицей; второй лист — «Остаток по дебету на начало» (сторона внутри названия).""" + first = build_osv( + OsvSpec( + inn_col=5, + first_sum_col=7, + hierarchy=hierarchy, + title=f"Оборотная ведомость {PERIOD_TAIL}", + group_titles={ + **GROUP_TITLES, + "opening": "Остаток на начало периода", + "closing": "Остаток на конец периода", + }, + split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}, + ), + base_tree(), + "Остатки", + ) + first.put(5, 1, "Остаток на начало: 0") + first.put(5, 3, "Остаток на конец: 0") + second = build_osv( + OsvSpec( + inn_col=None, + first_sum_col=3, + hierarchy=hierarchy, + title=f"Оборотная ведомость {PERIOD_TAIL}", + split_groups={ + "opening": ("Остаток по дебету на начало", "Остаток по кредиту на начало"), + "turnover": ("Оборот по дебету", "Оборот по кредиту"), + "closing": ("Остаток по дебету на конец", "Остаток по кредиту на конец"), + }, + ), + receivables_tree(), + "Остатки по сторонам", + ) + return [first, second] + + +def _s49() -> list[SheetSpec]: + return _s49_sheets() + + +def _s50() -> list[SheetSpec]: + """SAP: «Сальдо вх/исх Дт/Кт» (Виола, обороты 1С-вида — «Дт оборот» это AURORA-1722) и сторона, приклеенная к + названию группы «Сальдо НачальноеДТВВ» / «ОборотыДтВВ» (НМЖК); контрагент «ООО Дтвв-Сервис» — не сторона.""" + sap = OsvSpec( + inn_col=None, + first_sum_col=3, + title=f"Отчет по Главной книге {PERIOD_TAIL}", + split_groups={ + "opening": ("Сальдо вх Дт", "Сальдо вх Кт"), + "turnover": ("Оборот Дт", "Оборот Кт"), + "closing": ("Сальдо исх Дт", "Сальдо исх Кт"), + }, + ) + glued = OsvSpec( + inn_col=None, + first_sum_col=3, + split_groups={ + "opening": ("Сальдо НачальноеДТВВ", "Сальдо НачальноеКТВВ"), + "turnover": ("ОборотыДтВВ", "ОборотыКтВВ"), + "closing": ("Сальдо КонечноеДТВВ", "Сальдо КонечноеКТВВ"), + }, + ) + tree = base_tree() + tree.kids[0].kids[0].text = "ООО Дтвв-Сервис" + return [build_osv(sap, base_tree(), "SAP"), build_osv(glued, tree, "НМЖК")] + + +def _navision_sheet(name: str = "ОВ", hierarchy: Hierarchy = "outline", top_padding: int = 0) -> SheetSpec: + spec = OsvSpec( + untitled=True, + top_padding=top_padding, + inn_col=None, + first_sum_col=2, + hierarchy=hierarchy, + split_groups=NAVISION_GROUPS, + ) + return build_osv(spec, base_tree(), name) + + +def _s51() -> list[SheetSpec]: + """Navision без названия ведомости: таблица с первой строки, «НачалоДт | … | КонецКт» (Айсберри); запасной + признак.""" + return [_navision_sheet()] + + +def _s52() -> list[SheetSpec]: + """Форма ББ, лист Navision без названия, посторонний лист: форма остаётся формой, «Справка» — прочим.""" + return [statement_sheet(), _navision_sheet(), reference_sheet()] + + +def _s53() -> list[SheetSpec]: + """s49 в xls (BIFF5 без объединений и outline): отступ пробелами.""" + return _s49_sheets("spaces") + + +def _s54() -> list[SheetSpec]: + """s51 в xlsb (без объединений и outline): отступ пробелами.""" + return [_navision_sheet(hierarchy="spaces")] + + +def _s55() -> list[SheetSpec]: + """Негатив: названия, похожие на ведомость, но не ведомость (не с начала ячейки, «Оборотный капитал»), и отчёт 1С + «Обороты счета» с колонками корр. счетов между «Оборот Дт» и «Оборот Кт» (R-1) — все три листа прочие.""" + return [ + build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), + table_sheet( + "Справка", + [ + ["Справка: оборотная ведомость прилагается"], + [], + ["Показатель", "Сумма"], + ["Выручка", D("100000.00")], + ], + ), + table_sheet( + "Капитал", + [ + ["Оборотный капитал и его структура"], + [], + ["Показатель", "На начало", "На конец"], + ["Запасы", D("500.00"), D("700.00")], + ], + ), + table_sheet( + "Обороты счета", + [ + ["Обороты счета 60 за 9 месяцев 2025 г."], + [], + ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"] + + ["20", "Конечное сальдо Дт", "Конечное сальдо Кт"], + ["60", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")], + ["Итого", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")], + ], + ), + ] + + +def _s56() -> list[SheetSpec]: + """Негатив: безымянные таблицы, почти ОСВ — расшифровка «Остаток на начало | Поступило | Выбыло | Остаток на + конец», нет группы «начало», свёрнутое начало, полный заголовок ниже 30-й строки — все прочие (D1, R-2, R-3).""" + return [ + build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), + table_sheet( + "Расшифровка", + [ + ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"], + ["Материалы", D("100.00"), D("50.00"), D("30.00"), D("120.00")], + ], + ), + table_sheet( + "Без начала", + [ + ["Счет", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"], + ["60", D("10.00"), D("5.00"), D("5.00"), None], + ], + ), + table_sheet( + "Свёрнутое начало", + [ + ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"], + ["60", D("100.00"), D("10.00"), D("5.00"), D("105.00"), None], + ], + ), + _navision_sheet("Ниже 30", top_padding=30), + ] + + +def _s57() -> list[SheetSpec]: + """Негатив: форма ББ с примечанием «см. оборотную ведомость …» и текстом «Остаток на начало периода …», за ней + лист слабого продолжения (одна строка с кодом) — форма и продолжение остаются формой.""" + form = statement_sheet() + form.put(9, 1, "Примечание: см. оборотную ведомость по сч. 60") + form.put(10, 1, "Остаток на начало периода — по данным учёта") + continuation = table_sheet("Баланс (2)", [["Капитал и резервы", "1300", D("500000.00")]]) + return [form, continuation] + + def _normalized_zip(data: bytes) -> bytes: """Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время при каждом `save`, заданное в свойствах значение перезаписывает).""" @@ -1219,6 +1462,16 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), "s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx), + "s48_other_system_titles.xlsx": (_s48, to_xlsx), + "s49_balance_and_turnover_by_side.xlsx": (_s49, to_xlsx), + "s50_sap_in_out_glued_side.xlsx": (_s50, to_xlsx), + "s51_untitled_navision.xlsx": (_s51, to_xlsx), + "s52_mixed_book.xlsx": (_s52, to_xlsx), + "s53_balance_by_side.xls": (_s53, to_xls), + "s54_untitled_navision.xlsb": (_s54, to_xlsb), + "s55_not_osv_titles.xlsx": (_s55, to_xlsx), + "s56_almost_osv_tables.xlsx": (_s56, to_xlsx), + "s57_form_mentions_osv.xlsx": (_s57, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -1237,7 +1490,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, s40…s44 (xlsx, один xlsb и один xls) в указанный каталог.", + description="Синтетические ОСВ s01…s36, s40…s57 (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 684c908..e032e34 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,15 +26,19 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s57_with_two_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) - # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 - numbers = [*range(1, 37), *range(40, 48)] + # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44, ОСВ других систем (#81) — + # s48…s57 + numbers = [*range(1, 37), *range(40, 58)] 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 [name for name in names if name.endswith(".xlsb")] == [ + "s13_no_outline_indent.xlsb", + "s54_untitled_navision.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 1e21f8e..b6b3d02 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -54,6 +54,25 @@ FLAT_FORM_FILES = ( "s46_counterparty_code_blocks.xlsx", "s47_indent_hierarchy_code_sums.xlsx", ) + +# ОСВ других учётных систем (#81): контрольный набор — позитивы s48–s54 (на базе падают), негативы s55–s57 (вид листов +# и результат не меняются) +S48 = "s48_other_system_titles.xlsx" +S49 = "s49_balance_and_turnover_by_side.xlsx" +S50 = "s50_sap_in_out_glued_side.xlsx" +S51 = "s51_untitled_navision.xlsx" +S52 = "s52_mixed_book.xlsx" +S53 = "s53_balance_by_side.xls" +S54 = "s54_untitled_navision.xlsb" +S55 = "s55_not_osv_titles.xlsx" +S56 = "s56_almost_osv_tables.xlsx" +S57 = "s57_form_mentions_osv.xlsx" +OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57) +PENDING_DICTIONARY = "tp#81 Task 2: словари названий, групп и сторон" +PENDING_FALLBACK = "tp#81 Task 3: запасной признак листа по заголовку таблицы" +PENDING_81 = {S48: PENDING_DICTIONARY, S49: PENDING_DICTIONARY, S50: PENDING_DICTIONARY, S53: PENDING_DICTIONARY} +PENDING_81 |= {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK} +S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка") TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ "s01_variant2_header.xlsx", @@ -86,6 +105,7 @@ EXPECTED_NAMES = [ "s35_currency_slices.xlsx", "s36_no_data_type_header.xlsx", *FLAT_FORM_FILES, + *OTHER_SYSTEMS_FILES, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -194,6 +214,17 @@ EXPECTED: dict[str, Expect] = { "s47_indent_hierarchy_code_sums.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3 ), + # #81: названия других систем, остатки, SAP; безымянный лист — без шапки (организация, период, вид данных пусты) + S48: Expect("outline", 2025, "Q1", True, documents=len(S48_SHEETS)), + S49: Expect("outline", 2025, "Q1", True, documents=2), + S50: Expect("outline", 2025, "Q1", True, documents=2), + S51: Expect("outline", None, None, None, data_type=None, organization=""), + S52: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, data_type=None, organization=""), + S53: Expect("indent", 2025, "Q1", True, documents=2), + S54: Expect("indent", None, None, None, data_type=None, organization=""), + S55: Expect("outline", 2025, "Q1", True), + S56: Expect("outline", 2025, "Q1", True), + S57: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, documents=0), } @@ -283,8 +314,10 @@ 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: +def test_header_status_and_hierarchy(synthetic_file: Path, request: pytest.FixtureRequest) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" + if synthetic_file.name in PENDING_81: + request.applymarker(pytest.mark.xfail(strict=True, reason=PENDING_81[synthetic_file.name])) expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) @@ -810,3 +843,134 @@ def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> 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 + + +# --- ОСВ других учётных систем (#81): контрольный набор --- + +ALFA_SUMS = (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25")) +BUYER_SUMS = (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None) +TOTAL_NOT_FOUND = "строка «Итого» не найдена" + + +def sheet_other(outcome: ParseOutcome) -> list[str]: + """Листы, пропущенные как прочие (`SHEET_OTHER`), по порядку.""" + warnings = outcome.metrics.warnings if outcome.metrics is not None else None + return [w.locator or "" for w in warnings or [] if w.code == "SHEET_OTHER"] + + +def assert_done_with_sums(doc: OsvDocument, text: str, expected: tuple[Decimal | None, ...]) -> None: + """Документ сошёлся, и у строки `text` шесть сумм по сторонам: перепутанные Дебет/Кредит здесь не пройдут.""" + assert sums(by_text(doc)[text]) == expected + assert doc.status == "done", doc.checks + assert doc.rows[-1].group_type == "TOTAL" + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s48_other_system_titles_are_osv(fixtures_dir: Path) -> None: + """Каждое название ведомости — свой лист ОСВ; «ВСЕГО» и «Всего » — итоговая строка (AURORA-1717, 1732).""" + outcome = parse_named(fixtures_dir, S48) + + docs = documents(outcome) + assert [doc.locator for doc in docs] == [f"sheet:{name}" for name in S48_SHEETS] + assert sheet_other(outcome) == [] + for doc in docs: + assert_done_with_sums(doc, ALFA, ALFA_SUMS) + totals = {doc.locator: doc.rows[-1].row_value.strip() for doc in docs} + assert totals["sheet:Главная книга"] == "ВСЕГО" + assert totals["sheet:Вторая колонка"] == "Всего" + assert not [text for text in warnings_of(outcome) if TOTAL_NOT_FOUND in text] + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s49_balance_and_turnover_by_side(fixtures_dir: Path) -> None: + """«Остаток на начало/конец», «Оборот по дебету/кредиту», «Остаток по дебету на начало»; преамбула «Остаток на + начало: 0» над таблицей строкой групп не считается.""" + outcome = parse_named(fixtures_dir, S49) + + first, second = documents(outcome) + assert (first.locator, second.locator) == ("sheet:Остатки", "sheet:Остатки по сторонам") + assert_done_with_sums(first, ALFA, ALFA_SUMS) + assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None: + """SAP «Сальдо вх/исх Дт/Кт» и сторона, приклеенная к группе («ДТВВ»); «ООО Дтвв-Сервис» — контрагент.""" + outcome = parse_named(fixtures_dir, S50) + + sap, glued = documents(outcome) + assert (sap.locator, glued.locator) == ("sheet:SAP", "sheet:НМЖК") + assert_done_with_sums(sap, ALFA, ALFA_SUMS) + assert_done_with_sums(glued, "ООО Дтвв-Сервис", ALFA_SUMS) + assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY" + + +@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) +def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: + """Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак).""" + outcome = parse_named(fixtures_dir, S51) + + doc = one_document(outcome) + assert doc.locator == "sheet:ОВ" + assert sheet_other(outcome) == [] + assert_done_with_sums(doc, ALFA, ALFA_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) +def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None: + """Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ, + «Справка» — прочий лист.""" + outcome = parse_named(fixtures_dir, S52) + + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("OSV", "sheet:ОВ"), + ("BALANCE", "sheet:Бухгалтерский баланс"), + ] + assert sheet_other(outcome) == ["sheet:Справка"] + assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_DICTIONARY) +def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None: + """s49 в xls (без объединений): те же колонки и суммы.""" + outcome = parse_named(fixtures_dir, S53) + + first, second = documents(outcome) + assert_done_with_sums(first, ALFA, ALFA_SUMS) + assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) + + +@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) +def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None: + """s51 в xlsb: ОСВ по запасному признаку, те же суммы.""" + outcome = parse_named(fixtures_dir, S54) + + assert sheet_other(outcome) == [] + assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) + + +def test_neg_s55_titles_like_osv_are_other(fixtures_dir: Path) -> None: + """Название не с начала ячейки, «Оборотный капитал», 1С «Обороты счета» с корр. счетами между «Оборот Дт» и + «Оборот Кт» (R-1, R-11) — прочие листы; ОСВ одна.""" + outcome = parse_named(fixtures_dir, S55) + + assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] + assert sheet_other(outcome) == ["sheet:Справка", "sheet:Капитал", "sheet:Обороты счета"] + + +def test_neg_s56_almost_osv_tables_are_other(fixtures_dir: Path) -> None: + """Расшифровка с остатками, таблица без «начала», свёрнутое начало, заголовок ниже 30-й строки — прочие листы.""" + outcome = parse_named(fixtures_dir, S56) + + assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] + assert sheet_other(outcome) == ["sheet:Расшифровка", "sheet:Без начала", "sheet:Свёрнутое начало", "sheet:Ниже 30"] + + +def test_neg_s57_form_with_osv_words_stays_form(fixtures_dir: Path) -> None: + """Форма ББ с примечанием «см. оборотную ведомость» и слабым продолжением — форма из двух листов, ОСВ нет.""" + outcome = parse_named(fixtures_dir, S57) + + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("BALANCE", "sheet:Бухгалтерский баланс,Баланс (2)") + ] + assert sheet_other(outcome) == []