291 lines
15 KiB
Python
291 lines
15 KiB
Python
"""ББ и ОФР из книги проводок (спека §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)
|