Синтетика: восемь раскладок строки ИНН s20–s27, s18 с верными ИНН (#35)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 15:12:00 +04:00
parent 26ed11ad28
commit 1f96012884
2 changed files with 108 additions and 13 deletions

View File

@ -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)

View File

@ -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: