from datetime import timedelta from decimal import Decimal import openpyxl import pytest from synthgen import osv from synthgen.periods import build_periods ZERO = Decimal(0) SAMPLE_MERGES = { "A9:D9", "A10:D11", "E9:I9", "E10:G11", "H10:I11", "J9:N9", "J10:K11", "L10:N11", "O9:Q9", "O10:P11", "Q10:Q11", } def _pick(osv_world, account="62", inn=True): cfg, group, periods, books = osv_world member = next(m for m in group.members if (m.code not in group.no_inn) == inn) period = periods[-1] data = osv.rows(books[member.code], account, period, member, group) return member, period, data, group, books def _write(tmp_path, osv_world, account="62", inn=True): member, period, data, group, books = _pick(osv_world, account, inn) path = tmp_path / osv.file_name(account, period) osv.write(path, member, account, period, data, with_inn=inn) return openpyxl.load_workbook(path).active, member, period, data def test_file_name(): ps = build_periods("2025-Q1", 4) assert osv.file_name("60", ps[2]) == "сч 60_9 месяцев 2025 г.xlsx" assert osv.file_name("62", ps[3]) == "сч 62_2025 г.xlsx" def test_header_texts(tmp_path, osv_world): ws, member, period, _ = _write(tmp_path, osv_world, "60") assert ws["A1"].value == member.name assert ws["A2"].value == f"Оборотно-сальдовая ведомость по счету 60 за {period.label_1c}" text9 = " ".join(str(c.value) for c in ws[9] if c.value) text10 = " ".join(str(c.value) for c in ws[10] if c.value) assert "Счет" in text9 and "Сальдо на начало периода" in text9 assert "Обороты за период" in text9 and "Сальдо на конец периода" in text9 assert "Контрагенты" in text10 and "Дебет" in text10 + " ".join(str(c.value) for c in ws[11] if c.value) assert "Кредит" in text10 + " ".join(str(c.value) for c in ws[11] if c.value) def test_style_matches_sample(tmp_path, osv_world): ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False) assert ws["A12"].font.name == "Arial" and ws["A12"].font.sz == 8 assert ws["A2"].font.name == "Arial" and ws["A2"].font.sz == 12 and ws["A2"].font.b assert ws["A9"].fill.fgColor.rgb == "FFF4ECC5" assert ws["A12"].fill.fgColor.rgb == "FFF8F2D8" cp_row = next(i for i, r in enumerate(data, start=12) if r.is_cp) sub_row = next(i for i, r in enumerate(data, start=12) if r.level == 1) assert ws.cell(cp_row, 1).fill.fill_type is None assert ws["A9"].border.left.style and ws.cell(cp_row, 5).border.bottom.style assert ws.sheet_properties.outlinePr.summaryBelow is False assert ws.row_dimensions[sub_row].outlineLevel == 1 and ws.row_dimensions[cp_row].outlineLevel == 2 assert ws.cell(sub_row, 1).alignment.indent == 1 and ws.cell(cp_row, 1).alignment.indent == 2 number = next(c for c in ws[cp_row] if isinstance(c.value, float)) assert number.number_format == "#,##0.00" @pytest.mark.parametrize("inn", [False, True]) def test_every_amount_has_number_format(tmp_path, osv_world, inn): """Все суммы (сальдо и обороты, строки счёта, субсчёта, контрагента, «Итого») — `#,##0.00`, как в 1С.""" for account in ("60", "62", "76"): (tmp_path / account).mkdir() ws, *_ = _write(tmp_path / account, osv_world, account, inn=inn) bad = [c.coordinate for row in ws.iter_rows(min_row=12) for c in row if isinstance(c.value, (int, float)) and c.number_format != "#,##0.00"] assert not bad, (account, bad[:10]) def test_inn_header_styled_like_header(tmp_path, osv_world): ws, *_ = _write(tmp_path, osv_world, "60", inn=True) for r in (9, 10): # E10:E11 объединена — вид задаёт верхняя ячейка assert ws.cell(r, 5).fill.fgColor.rgb == ws.cell(r, 1).fill.fgColor.rgb == "FFF4ECC5", r assert ws.cell(r, 5).border.left.style and ws.cell(r, 5).border.top.style, r assert ws["E10"].font.name == "Arial" and ws["E10"].font.sz == 9 def test_merges_follow_sample(tmp_path, osv_world): ws, *_ = _write(tmp_path, osv_world, "62", inn=False) got = {str(m) for m in ws.merged_cells.ranges if m.min_row in (9, 10, 11)} assert SAMPLE_MERGES <= got (tmp_path / "x").mkdir() ws2, *_ = _write(tmp_path / "x", osv_world, "62", inn=True) got2 = {str(m) for m in ws2.merged_cells.ranges if m.min_row in (9, 10, 11)} assert {"A9:D9", "A10:D11", "E10:E11", "F9:J9", "F10:H11", "I10:J11", "K10:L11", "M10:O11", "P10:Q11", "R10:R11"} <= got2 assert ws2["E10"].value == "ИНН" def test_zero_is_empty(tmp_path, osv_world): ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False) for i, row in enumerate(data, start=12): spans = [(5, 7), (8, 9), (10, 11), (12, 14), (15, 16), (17, 17)] for (a, _), value in zip(spans, row.six, strict=True): cell = ws.cell(i, a).value assert (cell is None) if value == 0 else (cell == float(value)) def test_total_row(tmp_path, osv_world): ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False) last = 11 + len(data) + 1 assert ws.cell(last, 1).value == "Итого" totals = osv.total_of(data) account_row = next(r for r in data if r.level == 0) assert totals == account_row.six for (a, _), value in zip([(5, 7), (8, 9), (10, 11), (12, 14), (15, 16), (17, 17)], totals, strict=True): assert (ws.cell(last, a).value or 0) == pytest.approx(float(value)) def test_no_inn_file(tmp_path, osv_world): ws, member, _, data = _write(tmp_path, osv_world, "62", inn=False) assert ws.max_column <= 17 assert all(not (isinstance(c.value, str) and c.value.isdigit() and len(c.value) == 10) for c in ws["A"]) assert all(r.inn is None for r in data) assert all(ws.cell(i, 5).value is None or isinstance(ws.cell(i, 5).value, float) for i in range(12, 12 + len(data))) def test_rows_match_ledger(osv_world): """Каждая строка контрагента равна Ledger.balance/turnover по ключу (субсчёт, контрагент).""" cfg, group, periods, books = osv_world checked = 0 for member in group.members: ledger = books[member.code] for period in periods: data = osv.rows(ledger, "62", period, member, group) opening = ledger.balance(period.start - timedelta(days=1), "62") closing = ledger.balance(period.end, "62") turn = ledger.turnover(period.start, period.end, "62") def fold(src, pick): out = {} for (code, an), v in src.items(): key = (code, an.cp if an else None) out[key] = out.get(key, ZERO) + pick(v) return out want = { "o": fold(opening, lambda v: v), "c": fold(closing, lambda v: v), "td": fold(turn, lambda v: v[0]), "tc": fold(turn, lambda v: v[1]), } code = None seen = set() for r in data: if not r.is_cp: code = r.label continue key = (code, r.cp) seen.add(key) assert r.od - r.oc == want["o"].get(key, ZERO), (member.code, period.key, key) assert r.cd - r.cc == want["c"].get(key, ZERO), (member.code, period.key, key) assert r.td == want["td"].get(key, ZERO) and r.tc == want["tc"].get(key, ZERO), (member.code, period.key, key) checked += 1 # обратное: всё ненулевое из книги попало в строки for kind, src in want.items(): for key, v in src.items(): if v != 0: assert key in seen, (member.code, period.key, kind, key) assert checked > 20 def test_parent_sums(osv_world): _, _, data, _, _ = _pick(osv_world, "76") assert data[0].level == 0 subs = [r for r in data if r.level == 1] for col in range(6): assert data[0].six[col] == sum(r.six[col] for r in subs) # у субсчёта контрагенты суммируются в его строку for i, r in enumerate(data): if r.level == 1: kids = [] for k in data[i + 1:]: if k.level <= 1: break if k.is_cp: kids.append(k) if kids: assert r.six == tuple(sum(k.six[c] for k in kids) for c in range(6)) def test_group_names(osv_world): """В ОСВ без ИНН ровно один член группы записан вариантным именем, остальные — `name_1c`; с ИНН — все `name_1c`.""" cfg, group, periods, books = osv_world by_code = {m.code: m for m in group.members} variants_seen = 0 for member in group.members: others = [m for m in group.members if m.code != member.code] for account in ("60", "62", "76"): for period in periods: data = osv.rows(books[member.code], account, period, member, group) cps = {r.cp: r for r in data if r.is_cp} for m in others: if m.code not in cps: continue r = cps[m.code] if member.code in group.no_inn and group.variant_name[member.code] == m.code: assert r.label == osv.variant_spelling(m) != m.name_1c variants_seen += 1 else: assert r.label == m.name_1c, (member.code, account, period.key, m.code) assert r.inn == (None if member.code in group.no_inn else m.inn) if member.code in group.no_inn: variant = by_code[group.variant_name[member.code]] assert sum(1 for r in data if r.is_cp and r.label == variant.name_1c) == 0 assert variants_seen > 0, "вариантное написание нигде не встретилось" def test_osv_without_externals(tmp_path): from synthgen import dictionary from synthgen.config import Config from synthgen.counterparties import plan as make_plan from synthgen.group import build_group from synthgen.intragroup import build cfg = Config(group_size=3, periods=2, osv_counterparties_mean=0) group = build_group(cfg, dictionary.load()) ps = build_periods(cfg.start, cfg.periods) books, _ = build(cfg, group, ps, make_plan(cfg, group)) member = group.members[0] data = osv.rows(books[member.code], "60", ps[-1], member, group) path = tmp_path / "x.xlsx" osv.write(path, member, "60", ps[-1], data, with_inn=member.code not in group.no_inn) ws = openpyxl.load_workbook(path).active assert any(c.value == "Итого" for c in ws["A"])