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