2026-11-13 00:30:31 +04:00

291 lines
15 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.

"""ББ и ОФР из книги проводок (спека §5.6 п. 3 и 6, §6.2): сальдо на даты и обороты за периоды -> строки форм 66н.
Правила свёртки счетов в строки — из `synthetic-group/gen/forms.py` (#35) без вариантов FOOD/SERV/ФСБУ 4 и без намеренных
ошибок форм. Книга ведётся в рублях; `balance_rub` / `income_rub` отдают рубли по кодам строк, `balance` / `income` —
готовые формы в тыс. руб. целыми с `ROUND_HALF_UP` построчно, итоги — суммы округлённых строк; 1600 = 1700 обеспечивается
подгонкой ±1 тыс. строкам с наименьшей потерей точности (каждая строка — не более одного раза; такие строки записаны в
`Form.plugged`). Расходы ОФР в форме отрицательные.
Колонка берётся из книги, если её дата (ББ) или период (ОФР) лежит внутри книги; иначе — масштабированные значения первой
(книжной) колонки со множителем 0.8–1.1 на строку (решение 5), а в ББ строка 1370 уравнивает баланс (решение 35).
Закрывающие проводки месяца (`KIND_CLOSE_90`, `KIND_CLOSE_99`) в ОФР не входят, закрытие затрат (`KIND_CLOSE_COST`) входит.
"""
from dataclasses import dataclass, field
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from synthgen.chart import is_off_balance
from synthgen.group import Member
from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, Ledger
from synthgen.periods import Period, book_end, book_start
from synthgen.rng import rng_for
D = Decimal
ZERO = D(0)
ONE = D(1)
THOUSAND = D(1000)
RECEIVABLE_ROOTS = ("60", "62", "68", "69", "70", "71", "73", "75", "76") # Дт-сальдо -> 1230, Кт-сальдо -> 1520
BALANCE_CODES: tuple[str, ...] = (
"1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190", "1100",
"1210", "1220", "1230", "1240", "1250", "1260", "1200", "1600",
"1310", "1320", "1340", "1350", "1360", "1370", "1300",
"1410", "1420", "1430", "1450", "1400",
"1510", "1520", "1530", "1540", "1550", "1500", "1700",
) # fmt: skip
INCOME_CODES: tuple[str, ...] = (
"2110", "2120", "2100", "2210", "2220", "2200", "2310", "2320", "2330", "2340", "2350", "2300",
"2410", "2411", "2412", "2460", "2400", "2510", "2520", "2530", "2500", "2900", "2910",
) # fmt: skip
SECTION_TOTALS: dict[str, tuple[str, ...]] = {
"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"),
}
ASSET_LEAVES = SECTION_TOTALS["1100"] + SECTION_TOTALS["1200"]
LIABILITY_LEAVES = SECTION_TOTALS["1300"] + SECTION_TOTALS["1400"] + SECTION_TOTALS["1500"]
BALANCING_LEAF = "1370" # уравнивает баланс масштабированных колонок
INCOME_LEAVES = ("2110", "2120", "2210", "2220", "2310", "2320", "2330", "2340", "2350", "2410") # со знаком формы
SCALE_MIN, SCALE_MAX = 8000, 11000 # множитель 0.8–1.1 в десятитысячных
@dataclass(frozen=True)
class Form:
kind: str # BALANCE | INCOME
member: Member
period: Period
columns: list[str] # подписи колонок
values: dict[str, list[Decimal]] # код -> значения колонок, тыс. руб. целые; расходы ОФР отрицательные
dates: list[date] # ББ: даты колонок; ОФР: конец периода каждой колонки
from_book: list[bool] # колонка получена из книги (иначе масштабирована)
plugged: list[tuple[str, ...]] = field(default_factory=list) # ББ: строки, подогнанные ±1 в каждой колонке
# ---------------------------------------------------------------- рубли
def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]:
"""(код строки, сумма со знаком строки) для одного сальдо (Дт > 0)."""
root = acct.split(".")[0]
if root in ("01", "02", "03"):
return "1150", bal
if root == "08":
return "1190", bal
if root in ("10", "20", "26", "41", "43", "44"):
return "1210", bal
if root == "97":
return "1260", bal
if root == "19":
return "1220", bal
if root in ("50", "51", "52", "55"):
return "1250", bal
if acct == "58.01":
return "1170", bal
if acct == "58.03":
return ("1170" if member.is_head else "1240"), bal # займы головной — на срок > 12 мес.
if root in RECEIVABLE_ROOTS:
if acct == "76.07.1" and bal < 0:
return "1450", -bal # арендные обязательства считаем долгосрочными
if bal > 0:
return "1230", bal
return "1520", -bal
if root in ("66", "67") and bal < 0:
return ("1510" if root == "66" else "1410"), -bal
if root in ("80", "84", "99", "82", "83") and bal != 0:
return {"80": "1310", "82": "1360", "83": "1350"}.get(root, "1370"), -bal
if root == "96":
return "1540", -bal
raise ValueError(f"{member.code}: нет правила строки ББ для {acct} (сальдо {bal})")
def _balance_leaves(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]:
leaves = {c: ZERO for c in ASSET_LEAVES + LIABILITY_LEAVES}
for (acct, _), bal in sorted(ledger.balance(at).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
if acct == "000" or is_off_balance(acct):
continue
code, amount = _classify(acct, bal, member)
leaves[code] += amount
return leaves
def _add_totals(values: dict[str, Decimal]) -> dict[str, Decimal]:
out = dict(values)
for total, parts in SECTION_TOTALS.items():
out[total] = sum((out[c] for c in parts), ZERO)
out["1600"] = out["1100"] + out["1200"]
out["1700"] = out["1300"] + out["1400"] + out["1500"]
return out
def balance_rub(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]:
"""Код строки -> рубли (спека §5.6 п. 3): сальдо по строке, 000 и забалансовые не входят."""
full = _add_totals(_balance_leaves(ledger, at, member))
if full["1600"] != full["1700"]:
raise ValueError(f"{ledger.company} на {at}: актив {full['1600']} != пассив {full['1700']}")
return {c: full[c] for c in BALANCE_CODES}
def _monthly_income(ledger: Ledger) -> dict[tuple[int, int], dict[str, Decimal]]:
"""Обороты 90/91/99 по месяцам, рубли со знаком формы (доходы +, расходы −, 2410 = −налог); кэш в книге."""
cached = ledger._cache.get("forms_income")
if cached is not None:
return cached
out: dict[tuple[int, int], dict[str, Decimal]] = {}
for p in ledger.postings:
if p.kind in (KIND_CLOSE_90, KIND_CLOSE_99):
continue
row = out.setdefault((p.day.year, p.day.month), {})
for acct, an, sign in ((p.dt, p.dt_an, 1), (p.ct, p.ct_an, -1)): # Дт +, Кт −; в строку идёт −сальдо
item = an.item if an else None
code = {"90.01": "2110", "90.03": "2110", "90.02": "2120", "90.07": "2210", "90.08": "2220"}.get(acct)
if acct == "91.01":
code = {"Дивиденды": "2310", "Проценты": "2320"}.get(item, "2340")
elif acct == "91.02":
code = "2330" if item == "Проценты" else "2350"
if code:
row[code] = row.get(code, ZERO) - sign * p.amount
if p.dt == "99" and p.ct == "68.04": # налог на прибыль: расход — минус, корректировка — плюс
row["2410"] = row.get("2410", ZERO) - p.amount
elif p.dt == "68.04" and p.ct == "99":
row["2410"] = row.get("2410", ZERO) + p.amount
ledger._cache["forms_income"] = out
return out
def income_rub(ledger: Ledger, start: date, end: date) -> dict[str, Decimal]:
"""Строки-листья ОФР за период в рублях со знаком формы (расходы отрицательные)."""
if (start.month, start.day) != (1, 1) or (end + date.resolution).day != 1:
raise ValueError(f"период {start}..{end} не выровнен по месяцам")
monthly = _monthly_income(ledger)
res = {c: ZERO for c in INCOME_LEAVES}
y, m = start.year, start.month
while (y, m) <= (end.year, end.month):
for code, v in monthly.get((y, m), {}).items():
res[code] += v
y, m = (y + 1, 1) if m == 12 else (y, m + 1)
return res
# ---------------------------------------------------------------- единицы формы
def _thousands(x: Decimal) -> Decimal:
return (x / THOUSAND).quantize(ONE, rounding=ROUND_HALF_UP)
def _plug(rub: dict[str, Decimal]) -> tuple[dict[str, Decimal], tuple[str, ...]]:
"""Округление листьев ББ и подгонка так, чтобы актив и пассив сошлись (как «бухгалтерская» подгонка 1С): ±1 тыс. строкам
с наименьшей потерей точности среди строк с ненулевой суммой, каждой строке — не более одного раза."""
out = {c: _thousands(rub[c]) for c in ASSET_LEAVES + LIABILITY_LEAVES}
diff = sum((out[c] for c in ASSET_LEAVES), ZERO) - sum((out[c] for c in LIABILITY_LEAVES), ZERO)
if diff == 0:
return out, ()
step = -ONE if diff > 0 else ONE # шаг строке актива; пассиву — наоборот
cands = []
for c in ASSET_LEAVES + LIABILITY_LEAVES:
if rub[c] == 0:
continue
delta = step if c in ASSET_LEAVES else -step
cands.append((abs(out[c] + delta - rub[c] / THOUSAND), c, delta))
cands.sort()
need = int(abs(diff))
if need > len(cands):
raise ValueError(f"нечего подгонять: расхождение {diff} тыс., строк {len(cands)}")
for _, c, delta in cands[:need]:
out[c] += delta
return out, tuple(sorted(c for _, c, _ in cands[:need]))
def _scale(rng_keys: tuple, code: str, value: Decimal, seed: int, kind: str) -> Decimal:
factor = D(rng_for(seed, "prev", code, kind, *rng_keys).randint(SCALE_MIN, SCALE_MAX)) / D(10000)
return value * factor
# ---------------------------------------------------------------- формы
_MONTHS_WORDS = {1: "3 месяца", 2: "6 месяцев", 3: "9 месяцев"}
def balance_dates(period: Period) -> list[date]:
return [period.end, date(period.year - 1, 12, 31), date(period.year - 2, 12, 31)]
def income_ends(period: Period) -> list[date]:
return [period.end, date(period.year - 1, period.end.month, period.end.day)]
def _balance_titles(period: Period) -> list[str]:
first = f"На 31 декабря\n{period.year} года" if period.quarter == 4 else f"За {_MONTHS_WORDS[period.quarter]}\n{period.year} года"
return [first, f"На 31 декабря\n{period.year - 1} года", f"На 31 декабря\n{period.year - 2} года"]
def _income_titles(period: Period) -> list[str]:
if period.quarter == 4:
return [f"За {period.year} год", f"За {period.year - 1} год"]
words = _MONTHS_WORDS[period.quarter]
return [f"За {words}\n{period.year} года", f"За {words}\n{period.year - 1} года"]
def balance(ledger: Ledger, member: Member, period: Period, ps: list[Period], seed: int) -> Form:
"""ББ за период: на конец периода, на 31.12 прошлого года и позапрошлого (в книге — из книги, иначе масштаб)."""
dates = balance_dates(period)
lo, hi = book_start(ps), book_end(ps)
cols: list[dict[str, Decimal]] = []
from_book: list[bool] = []
plugged: list[tuple[str, ...]] = []
base_rub: dict[str, Decimal] | None = None
for i, day in enumerate(dates):
if lo <= day <= hi:
rub = balance_rub(ledger, day, member)
base_rub = base_rub or rub
values, plug = _plug(rub)
from_book.append(True)
else:
assert base_rub is not None, "первая колонка ББ всегда в книге"
scaled = {c: _scale((member.code, period.key, i), c, base_rub[c], seed, "BALANCE") for c in ASSET_LEAVES + LIABILITY_LEAVES}
others = sum((scaled[c] for c in LIABILITY_LEAVES if c != BALANCING_LEAF), ZERO)
scaled[BALANCING_LEAF] = sum((scaled[c] for c in ASSET_LEAVES), ZERO) - others
values, plug = _plug(scaled)
from_book.append(False)
cols.append(_add_totals(values))
plugged.append(plug)
return Form(
"BALANCE", member, period, _balance_titles(period), {c: [col[c] for col in cols] for c in BALANCE_CODES}, dates,
from_book, plugged,
) # fmt: skip
def _income_column(leaves: dict[str, Decimal]) -> dict[str, Decimal]:
r = {c: _thousands(leaves[c]) for c in INCOME_LEAVES}
r["2100"] = r["2110"] + r["2120"]
r["2200"] = r["2100"] + r["2210"] + r["2220"]
r["2300"] = r["2200"] + r["2310"] + r["2320"] + r["2330"] + r["2340"] + r["2350"]
r["2411"] = r["2410"] # налог — текущий; отложенного в модели нет
r["2412"] = r["2460"] = r["2510"] = r["2520"] = r["2530"] = r["2900"] = r["2910"] = ZERO
r["2400"] = r["2300"] + r["2410"] + r["2460"]
r["2500"] = r["2400"] + r["2510"] + r["2520"] + r["2530"]
return r
def income(ledger: Ledger, member: Member, period: Period, ps: list[Period], seed: int) -> Form:
"""ОФР за период и за тот же период прошлого года (в книге — из книги, иначе масштаб первой колонки)."""
ends = income_ends(period)
lo, hi = date(ps[0].year, 1, 1), book_end(ps)
cols: list[dict[str, Decimal]] = []
from_book: list[bool] = []
base: dict[str, Decimal] | None = None
for i, end in enumerate(ends):
start = date(end.year, 1, 1)
if lo <= start and end <= hi:
leaves = income_rub(ledger, start, end)
base = base or leaves
from_book.append(True)
else:
assert base is not None, "первая колонка ОФР всегда в книге"
leaves = {c: _scale((member.code, period.key, i), c, base[c], seed, "INCOME") for c in INCOME_LEAVES}
from_book.append(False)
cols.append(_income_column(leaves))
return Form("INCOME", member, period, _income_titles(period), {c: [col[c] for col in cols] for c in INCOME_CODES}, ends, from_book)