253 lines
11 KiB
Python
253 lines
11 KiB
Python
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
|
||
for m, p, bal, _ in built:
|
||
rub = forms.balance_rub(books[m.code], p.end, m, forms_world[0].seed)
|
||
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)
|
||
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)
|
||
|
||
|
||
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, forms_world[0].seed)
|
||
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, forms_world[0].seed)
|
||
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 года"
|
||
|
||
|
||
def test_head_loans_by_term(forms_world):
|
||
"""Решение 42: займы головной классифицируются по сроку кредиторского субсчёта дочерней (правила 3/4 ядра)."""
|
||
from synthgen.intragroup import loan_account
|
||
|
||
cfg, group, periods, books = forms_world
|
||
head = group.members[0]
|
||
at = periods[0].end
|
||
rub = forms.balance_rub(books[head.code], at, head, cfg.seed)
|
||
bal = books[head.code].balance(at)
|
||
terms = {c.code: loan_account(cfg, c.code) for c in group.members[1:]}
|
||
assert {"67.03", "66.03"} <= set(terms.values()) or len(terms) < 3
|
||
long_ = sum((v for (a, an), v in bal.items() if a == "58.03" and an and terms.get(an.cp) == "67.03"), ZERO)
|
||
other = sum((v for (a, an), v in bal.items() if a == "58.03" and not (an and terms.get(an.cp) == "67.03")), ZERO)
|
||
assert long_ > 0
|
||
assert rub["1170"] == long_ + sum((v for (a, _), v in bal.items() if a == "58.01"), ZERO)
|
||
assert rub["1240"] >= other
|