2026-11-13 01:04:15 +04:00

253 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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