From 1f96012884d87e92010587cfdb4c67930567f9ed Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 15:12:00 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0:=20=D0=B2=D0=BE=D1=81=D0=B5=D0=BC=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20s20=E2=80=93s27?= =?UTF-8?q?,=20s18=20=D1=81=20=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=BC=D0=B8?= =?UTF-8?q?=20=D0=98=D0=9D=D0=9D=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/synthetic/generate.py | 74 ++++++++++++++++++--- api/tests/synthetic/test_synthetic_files.py | 47 +++++++++++-- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 4c13f50..dc7f97a 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -1,7 +1,8 @@ """Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`. Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных -ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание — +ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь +раскладок строки ИНН (#35); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -69,9 +70,11 @@ class Node: num_style: NumStyle = "num" inn_style: InnStyle = "column" closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется + blank: bool = False # строка без сумм (строка ИНН без сумм, #35) + twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит def children(self) -> list["Node"]: - return [kid for kid in self.kids if not kid.indicator] + return [kid for kid in self.kids if not kid.indicator and kid.twin is None] def six(self) -> Six: """Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи).""" @@ -365,7 +368,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec: inn_line = Node(node.inn) _row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True) next_row += 1 - _row_cells(sheet, spec, next_row, (node, depth), node.six()) + _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()) @@ -628,10 +631,13 @@ def _s17() -> list[SheetSpec]: def _s18() -> list[SheetSpec]: - """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.""" - root = Node( - "60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])] - ) + """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет. + ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35).""" + first = alfa(inn_style="above") + first.inn = "7701234560" + third = gamma(inn_style="above") + third.inn = "5001234561" + root = Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third])]) return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] @@ -645,6 +651,50 @@ def _s19() -> list[SheetSpec]: return [build_osv(spec, root)] +InnLayout = Literal["above_sibling", "above_parent", "below_child", "below_sibling"] + + +def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node: + """То же дерево, где ИНН каждого контрагента выведен отдельной строкой в раскладке `layout` (#35): над / под + наименованием, в дереве (родитель / ребёнок) или соседом; `sums` — у строки ИНН те же суммы, иначе строка пустая. + Колонки ИНН нет, в названии ИНН не пишется.""" + kids: list[Node] = [] + for kid in node.kids: + kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией с строками ИНН + if kid.inn is None or kid.indicator: + kids.append(kid) + continue + inn, kid.inn = kid.inn, None + if layout == "above_sibling": + kids += [Node(inn, twin=kid, blank=not sums), kid] + elif layout == "below_sibling": + kids += [kid, Node(inn, twin=kid, blank=not sums)] + elif layout == "above_parent": + kids.append(Node(inn, [kid], blank=not sums)) + else: + kid.kids = [Node(inn, kid.kids, opening=kid.opening, turnover=kid.turnover, blank=not sums)] + kids.append(kid) + node.kids = kids + return node + + +def _inn_layout_tree() -> Node: + """Счёт 60 для раскладок строк ИНН (#35): ИНН с верной контрольной суммой (10 и 12 цифр), физлицо без ИНН.""" + first = alfa() + first.inn = "7701234560" + third = gamma() + third.inn = "5001234561" + return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) + + +def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]: + def build() -> list[SheetSpec]: + root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums) + return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] + + return build + + def _normalized_zip(data: bytes) -> bytes: """Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время при каждом `save`, заданное в свойствах значение перезаписывает).""" @@ -713,6 +763,14 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s17_inn_in_name.xlsx": (_s17, to_xlsx), "s18_inn_row_above.xlsx": (_s18, to_xlsx), "s19_inn_kpp_cell.xlsx": (_s19, to_xlsx), + "s20_inn_above_sibling_blank.xlsx": (_inn_layout("above_sibling", sums=False), to_xlsx), + "s21_inn_above_sibling_sums.xlsx": (_inn_layout("above_sibling", sums=True), to_xlsx), + "s22_inn_above_parent_blank.xlsx": (_inn_layout("above_parent", sums=False), to_xlsx), + "s23_inn_above_parent_sums.xlsx": (_inn_layout("above_parent", sums=True), to_xlsx), + "s24_inn_below_child_blank.xlsx": (_inn_layout("below_child", sums=False), to_xlsx), + "s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx), + "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), + "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), } FILE_NAMES = tuple(FILES) @@ -731,7 +789,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…s19 (xlsx и один xlsb) в указанный каталог.", + description="Синтетические ОСВ s01…s27 (xlsx и один xlsb) в указанный каталог.", ) 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 9ddfc91..a687ea5 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -26,6 +26,16 @@ def _require_synthetic(synthetic_dir: Path) -> None: 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", +) EXPECTED_NAMES = [ "s01_variant2_header.xlsx", "s02_no_outline_indent.xlsx", @@ -46,6 +56,7 @@ EXPECTED_NAMES = [ "s17_inn_in_name.xlsx", "s18_inn_row_above.xlsx", "s19_inn_kpp_cell.xlsx", + *INN_LAYOUT_FILES, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -109,6 +120,8 @@ EXPECTED: dict[str, Expect] = { "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}, } @@ -180,7 +193,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…s19 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s27 (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] @@ -518,19 +531,43 @@ def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> N assert "inn" not in doc.columns -@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True) 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 {"7701234567", "5001234567"}] + 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: "7701234567", BETA: None, "Иванов Иван": None, GAMMA: "5001234567"} - assert any("ИНН 7701234567" in warning for warning in warnings_of(outcome)) + 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_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: