from collections import defaultdict from datetime import date from decimal import ROUND_HALF_UP, Decimal import pytest from synthgen import forms from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99 from synthgen.periods import book_start ZERO = Decimal(0) SEED = 1 SECTIONS = { "1100": ("1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190"), "1200": ("1210", "1220", "1230", "1240", "1250", "1260"), "1300": ("1310", "1320", "1340", "1350", "1360", "1370"), "1400": ("1410", "1420", "1430", "1450"), "1500": ("1510", "1520", "1530", "1540", "1550"), } def _thousands(x: Decimal) -> Decimal: return (x / 1000).quantize(Decimal(1), rounding=ROUND_HALF_UP) @pytest.fixture(scope="module") def built(forms_world): cfg, group, periods, books = forms_world out = [] for m in group.members: for p in periods: out.append((m, p, forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed))) return out def test_codes(): assert forms.BALANCE_CODES[0] == "1110" and forms.BALANCE_CODES[-1] == "1700" and "1215" not in forms.BALANCE_CODES assert forms.INCOME_CODES[-3:] == ("2500", "2900", "2910") assert {"2530", "2900", "2910", "2411", "2412", "2460"} <= set(forms.INCOME_CODES) def test_form_shape(built): for m, p, bal, inc in built: assert bal.kind == "BALANCE" and inc.kind == "INCOME" and bal.member is m and bal.period is p assert len(bal.columns) == 3 and len(inc.columns) == 2 assert set(bal.values) == set(forms.BALANCE_CODES) and set(inc.values) == set(forms.INCOME_CODES) assert all(len(v) == 3 for v in bal.values.values()) and all(len(v) == 2 for v in inc.values.values()) assert all(x == x.to_integral_value() for v in bal.values.values() for x in v), "тыс. руб. целыми" def test_balance_identity(built): for m, p, bal, _ in built: for col in range(3): v = {c: bal.values[c][col] for c in forms.BALANCE_CODES} for total, parts in SECTIONS.items(): assert v[total] == sum((v[c] for c in parts), ZERO), (m.code, p.key, col, total) assert v["1600"] == v["1100"] + v["1200"] assert v["1700"] == v["1300"] + v["1400"] + v["1500"] assert v["1600"] == v["1700"], (m.code, p.key, col) def test_balance_matches_ledger(built, forms_world): _, _, _, books = forms_world adjusted = 0 for m, p, bal, _ in built: rub = forms.balance_rub(books[m.code], p.end, m) for code in forms.BALANCE_CODES: if code in SECTIONS or code in ("1600", "1700"): continue want = _thousands(rub[code]) got = bal.values[code][0] if code in bal.plugged[0]: assert abs(got - want) == 1, (m.code, p.key, code) adjusted += 1 else: assert got == want, (m.code, p.key, code, got, want) assert bal.values["1600"][0] == sum((bal.values[c][0] for c in SECTIONS["1100"] + SECTIONS["1200"]), ZERO) assert adjusted >= 0 def test_plug_is_minimal_and_recorded(built): for _, p, bal, _ in built: assert all(len(set(cols)) == len(cols) for cols in bal.plugged) def test_first_prior_column_from_book(built, forms_world): _, _, periods, books = forms_world first = periods[0] day = book_start(periods) assert day == date(first.year - 1, 12, 31) for m, p, bal, _ in built: if p.year != first.year: continue rub = forms.balance_rub(books[m.code], day, m) for code in forms.BALANCE_CODES: if code in SECTIONS or code in ("1600", "1700"): continue if code in bal.plugged[1]: assert abs(bal.values[code][1] - _thousands(rub[code])) == 1 else: assert bal.values[code][1] == _thousands(rub[code]), (m.code, p.key, code) assert bal.from_book[1] and not bal.from_book[2] def test_columns_inside_book_come_from_book(built, forms_world): _, _, periods, books = forms_world last = periods[-1] # 2026-Q1: 31.12.2025 и 31.12.2024 лежат в книге for m, p, bal, _ in built: if p is not last: continue assert bal.from_book == [True, True, True] for col, day in enumerate((p.end, date(2025, 12, 31), date(2024, 12, 31))): rub = forms.balance_rub(books[m.code], day, m) for code in ("1150", "1230", "1250", "1520", "1370"): assert abs(bal.values[code][col] - _thousands(rub[code])) <= 1, (m.code, col, code) def test_scaled_columns(built): for m, p, bal, _ in built: if bal.from_book[2]: continue for code in ("1150", "1210", "1230", "1250", "1410", "1520"): base, got = bal.values[code][0], bal.values[code][2] if base == 0: assert got == 0 else: assert base * Decimal("0.8") - 1 <= got <= base * Decimal("1.1") + 1, (m.code, p.key, code, base, got) def test_forms_deterministic(forms_world, built): cfg, group, periods, books = forms_world m, p = group.members[0], periods[0] again = forms.balance(books[m.code], m, p, periods, cfg.seed) assert again.values == next(b for mm, pp, b, _ in built if mm is m and pp is p).values other = forms.balance(books[m.code], m, p, periods, cfg.seed + 1) assert other.values["1600"][0] == again.values["1600"][0] and other.values != again.values def _income_expected(ledger, start, end): net: dict[tuple[str, str | None], Decimal] = defaultdict(Decimal) tax = ZERO for pst in ledger.postings: if not start <= pst.day <= end or pst.kind in (KIND_CLOSE_90, KIND_CLOSE_99): continue for acct, an, sign in ((pst.dt, pst.dt_an, 1), (pst.ct, pst.ct_an, -1)): item = an.item if an else None if acct.startswith("90") or acct.startswith("91"): net[(acct, item if item in ("Дивиденды", "Проценты") else None)] += sign * pst.amount if pst.dt == "99" and pst.ct == "68.04": tax += pst.amount elif pst.dt == "68.04" and pst.ct == "99": tax -= pst.amount g = lambda a, i=None: net.get((a, i), ZERO) # noqa: E731 return { "2110": -g("90.01") - g("90.03"), "2120": -g("90.02"), "2210": -g("90.07"), "2220": -g("90.08"), "2310": -g("91.01", "Дивиденды"), "2320": -g("91.01", "Проценты"), "2330": -g("91.02", "Проценты"), "2340": -g("91.01"), "2350": -g("91.02"), "2410": -tax, } # fmt: skip def test_income_matches_ledger(built, forms_world): _, _, _, books = forms_world nonzero = set() for m, p, _, inc in built: exp = _income_expected(books[m.code], p.start, p.end) for code, rub in exp.items(): assert inc.values[code][0] == _thousands(rub), (m.code, p.key, code, inc.values[code][0], rub) if rub: nonzero.add(code) assert {"2110", "2120", "2220", "2410"} <= nonzero # прочих доходов/расходов (2340/2350) экономика не создаёт def test_income_signs(built): for _, _, _, inc in built: for code in ("2120", "2210", "2220", "2330", "2350"): assert all(x <= 0 for x in inc.values[code]), code for code in ("2110", "2310", "2320", "2340"): assert all(x >= 0 for x in inc.values[code]), code def test_2400_flow(built): for m, p, _, inc in built: for col in range(2): v = {c: inc.values[c][col] for c in forms.INCOME_CODES} assert v["2400"] == v["2300"] + v["2410"] + v["2460"], (m.code, p.key, col) assert v["2100"] == v["2110"] + v["2120"] assert v["2200"] == v["2100"] + v["2210"] + v["2220"] assert v["2300"] == v["2200"] + v["2310"] + v["2320"] + v["2330"] + v["2340"] + v["2350"] assert v["2410"] == v["2411"] + v["2412"] assert v["2500"] == v["2400"] + v["2510"] + v["2520"] + v["2530"] assert v["2900"] == 0 and v["2910"] == 0 and v["2460"] == 0 def test_net_profit_equals_99_balance(built, forms_world): """2400 — тот же результат, что копится на 99 (до реформации в декабре); закрытия 90.09/99 в ОФР не задваивают.""" _, _, _, books = forms_world checked = 0 for m, p, _, inc in built: if p.quarter == 4: continue bal99 = books[m.code].balance(p.end, "99").get(("99", None), ZERO) assert abs(inc.values["2400"][0] * 1000 + bal99) <= 5000, (m.code, p.key) checked += 1 assert checked def test_prior_year_column_from_book(built, forms_world): _, _, periods, books = forms_world last = periods[-1] # 2026-Q1: прошлогодний 1 кв. 2025 лежит в книге for m, p, _, inc in built: if p is not last: continue assert inc.from_book == [True, True] exp = _income_expected(books[m.code], date(2025, 1, 1), date(2025, 3, 31)) for code, rub in exp.items(): assert inc.values[code][1] == _thousands(rub), (m.code, code) def test_prior_year_column_scaled(built): for m, p, _, inc in built: if inc.from_book[1]: continue for code in ("2110", "2120", "2220"): base, got = inc.values[code][0], inc.values[code][1] lo, hi = sorted((base * Decimal("0.8"), base * Decimal("1.1"))) assert lo - 1 <= got <= hi + 1, (m.code, p.key, code, base, got) def test_column_labels(built): by = {p.key: (bal, inc) for m, p, bal, inc in built if m.code == built[0][0].code} bal, inc = by["2025-Q3"] assert bal.columns == ["За 9 месяцев\n2025 года", "На 31 декабря\n2024 года", "На 31 декабря\n2023 года"] assert inc.columns == ["За 9 месяцев\n2025 года", "За 9 месяцев\n2024 года"] bal, inc = by["2025-FY"] assert bal.columns[0] == "На 31 декабря\n2025 года" and inc.columns == ["За 2025 год", "За 2024 год"] assert by["2025-Q1"][1].columns[0] == "За 3 месяца\n2025 года" assert by["2025-Q2"][1].columns[1] == "За 6 месяцев\n2024 года"