"""Операции внутри группы и расхождения ВГО (спека §5.4–5.5). Операции проводятся в двух книгах одновременно и симметрично: - доли: входящие остатки, 58.01 у головной по дочерней ↔ 80.09 у дочерней по головной (миноритарий — только в 80.09); - займы: головная → каждая дочерняя, 58.03 ↔ 66.03/67.03 (входящие остатки), проценты ежемесячно 76.09 ↔ 76.09 / 91; - торговля: 62.01 продавца ↔ 60.01 покупателя, оплата частично (остаток на конец периода — сальдо пары); - дивиденды: во 2 квартале (май), объявлены и выплачены в одном месяце (решение 11): сальдо 76.03 на конец периода 0; - расхождения: в последнем месяце первого периода года одна сторона проводит «накладную в пути» (торговля) или доначисление процентов (заём), вторая проводит её 31 января следующего года (если январь есть в книге, решение 13). Аналитика группы — `An(cp=<код члена>)`; внешние контрагенты в 60/62 остаются с `An(cp=<ИНН>)`. """ from dataclasses import dataclass from datetime import date from decimal import Decimal import math from synthgen.config import Config from synthgen.counterparties import CounterpartyPlan from synthgen.economy import ( FNS, CompanyModel, build_ledgers, capital_of, last_day, q, vat_of, ) from synthgen.group import Group, Member from synthgen.ledger import An, Ledger from synthgen.periods import Period from synthgen.rng import rng_for D = Decimal THRESHOLD = D(5) # порог ДП §4, процентов LOAN_RATE = D("0.10") # годовая ставка по займам группы DIVIDEND_MONTH = 5 # 2 квартал DIVIDEND_SHARE = D("0.03") # дивиденды дочерней: доля годовой выручки BUYER_CAP = D("0.10") # закупки у группы не больше доли годовой выручки покупателя (с НДС) @dataclass(frozen=True) class Mismatch: side: str # код стороны, у которой проводка есть year: int period_key: str amount: Decimal pct: Decimal # процентов от меньшей стороны сальдо пары на конец периода above_threshold: bool @dataclass(frozen=True) class VgoPair: a: str # продавец / займодавец b: str kind: str # trade | loan mismatch: Mismatch | None = None # расхождение первого из лет (для удобства; все — в `mismatches`) mismatches: tuple[Mismatch, ...] = () # по одному на год, где пара выбрана (решение 27) def loan_account(cfg: Config, child_code: str) -> str: """Субсчёт кредитора по займу головной: краткосрочный 66.03 или долгосрочный 67.03.""" return "66.03" if rng_for(cfg.seed, "loan-term", child_code).random() < 0.5 else "67.03" def plan_pairs(cfg: Config, group: Group) -> list[VgoPair]: """Пары ВГО без расхождений: торговля — упорядоченная пара с вероятностью 0.5; займы — головная → дочерние.""" pairs: list[VgoPair] = [] for a in group.members: for b in group.members: if a.code != b.code and rng_for(cfg.seed, "trade", a.code, b.code).random() < 0.5: pairs.append(VgoPair(a.code, b.code, "trade")) head = group.members[0] for child in group.members[1:]: pairs.append(VgoPair(head.code, child.code, "loan")) return pairs # ---------- входящие остатки: доли и займы ---------- def _loan_principal(cfg: Config, child: CompanyModel) -> Decimal: return q(child.rev_year * D(rng_for(cfg.seed, "loan-principal", child.code).randint(30, 60)) / 1000) def _head_part(member: Member, cap: Decimal) -> Decimal: return q(cap * (member.head_share or D(1))) def _opening_hook(cfg: Config, group: Group): head = group.members[0] children = {m.code: m for m in group.members[1:]} def hook(member: Member, models: dict[str, CompanyModel], lines): if member.is_head: out = list(lines) for code, child in children.items(): cm = models[code] out.append(("58.01", An(cp=code), _head_part(child, capital_of(cm)))) out.append(("58.03", An(cp=code), _loan_principal(cfg, cm))) return out cm = models[member.code] cap = capital_of(cm) out = [ln for ln in lines if ln[0] != "80"] part = _head_part(member, cap) out.append(("80.09", An(cp=head.code), -part)) if cap - part > 0: if member.minority is None: raise ValueError(f"{member.code}: доля головной {member.head_share} без миноритария") out.append(("80.09", An(cp=member.minority.inn), -(cap - part))) out.append((loan_account(cfg, member.code), An(cp=head.code), -_loan_principal(cfg, cm))) return out return hook # ---------- проводки сторон ---------- def _seller_invoice(lg: Ledger, day: date, buyer: str, gross: Decimal, memo: str) -> None: lg.post(day, "62.01", An(cp=buyer), "90.01", None, gross, memo) vat = vat_of(gross) if vat > 0: lg.post(day, "90.03", None, "68", FNS, vat, "НДС с реализации") def _buyer_invoice(lg: Ledger, day: date, seller: str, gross: Decimal, memo: str) -> None: vat = vat_of(gross) net = gross - vat lg.post(day, "41", None, "60.01", An(cp=seller), net, memo) if vat > 0: lg.post(day, "19", None, "60.01", An(cp=seller), vat, "НДС по закупке") lg.post(day, "68", FNS, "19", None, vat, "НДС к вычету") lg.post(day, "90.02", None, "41", None, net, "себестоимость") def _interest(principal: Decimal) -> Decimal: return q(principal * LOAN_RATE / 12) def _lender_interest(lg: Ledger, day: date, borrower: str, amount: Decimal, memo: str) -> None: lg.post(day, "76.09", An(cp=borrower), "91.01", An(cp=borrower, item="Проценты"), amount, memo) def _borrower_interest(lg: Ledger, day: date, lender: str, amount: Decimal, memo: str) -> None: lg.post(day, "91.02", An(cp=lender, item="Проценты"), "76.09", An(cp=lender), amount, memo) # ---------- сальдо пары ---------- def _sum_cp(lg: Ledger, at: date, accounts: tuple[str, ...], cp: str) -> Decimal: total = D(0) for acct in accounts: for (_, an), v in lg.balance(at, acct).items(): if an is not None and an.cp == cp: total += v return total def pair_balance(ledgers: dict[str, Ledger], pair: VgoPair, at: date) -> tuple[Decimal, Decimal]: """Сальдо у `a` по `b` и у `b` по `a` по счетам пары: торговля 62.01/62.02 ↔ 60.01/60.02; заём 58.03 + 76.09 ↔ 66.03 и 67.03 + 76.09 (оба субсчёта займов, у конкретной пары занят один).""" if pair.kind == "trade": acc_a, acc_b = ("62.01", "62.02"), ("60.01", "60.02") else: acc_a, acc_b = ("58.03", "76.09"), ("66.03", "67.03", "76.09") return _sum_cp(ledgers[pair.a], at, acc_a, pair.b), _sum_cp(ledgers[pair.b], at, acc_b, pair.a) # ---------- построитель ---------- class _Builder: def __init__(self, cfg: Config, group: Group, ps: list[Period]): self.cfg, self.group, self.ps = cfg, group, ps self.pairs = plan_pairs(cfg, group) self.trades = [p for p in self.pairs if p.kind == "trade"] self.loans = [p for p in self.pairs if p.kind == "loan"] self.vol: dict[tuple[str, str], Decimal] = {} # годовой объём (с НДС) пары self.pay: dict[tuple[str, str], Decimal] = {} # доля оплаты в месяце счёта self.principal: dict[str, Decimal] = {} self.models: dict[str, CompanyModel] = {} self.found: dict[int, dict[int, Mismatch]] = {} # индекс пары → год → расхождение self.first_period: dict[int, Period] = {} for p in ps: self.first_period.setdefault(p.year, p) self.plan_mm = self._plan_mismatches() # -- расчёт условий -- def _init_terms(self, ledgers: dict[str, Ledger]) -> None: self.models = {c: lg.meta["model"] for c, lg in ledgers.items()} raw: dict[tuple[str, str], Decimal] = {} for p in self.trades: share = D(rng_for(self.cfg.seed, "trade-vol", p.a, p.b).randint(50, 200)) / 1000 raw[(p.a, p.b)] = self.models[p.a].rev_year * share * (1 + D("0.2")) self.pay[(p.a, p.b)] = D(rng_for(self.cfg.seed, "trade-pay", p.a, p.b).randint(50, 90)) / 100 for b in {p.b for p in self.trades}: tot = sum((v for (_, bb), v in raw.items() if bb == b), D(0)) cap = self.models[b].rev_year * (1 + D("0.2")) * BUYER_CAP k = min(D(1), cap / tot) if tot > 0 else D(1) for key in raw: if key[1] == b: raw[key] = raw[key] * k self.vol = raw for p in self.loans: self.principal[p.b] = _loan_principal(self.cfg, self.models[p.b]) def _amount(self, p: VgoPair, year: int, month: int) -> Decimal: noise = D(rng_for(self.cfg.seed, "trade-noise", p.a, p.b, year, month).randint(90, 110)) / 100 return q(self.vol[(p.a, p.b)] / 12 * noise) # -- расхождения -- def _plan_mismatches(self) -> dict[int, list[tuple[int, str, Decimal]]]: """год → [(индекс пары, сторона, процент)] в порядке выбора; первые n//2 — малые (1–4 %), остальные — крупные.""" share = self.cfg.vgo_mismatch_share n = len(self.pairs) out: dict[int, list[tuple[int, str, Decimal]]] = {} if n == 0 or share <= 0: return out k = max(1, math.floor(share * n)) k = min(k, n) for year in self.first_period: idx = rng_for(self.cfg.seed, "mismatch", year).sample(range(n), k) small = k // 2 rows = [] for j, i in enumerate(idx): rng = rng_for(self.cfg.seed, "mismatch-pct", year, i) pct = D(rng.randint(105, 395)) / 100 if j < small else D(rng.randint(805, 2495)) / 100 pair = self.pairs[i] side = pair.a if rng_for(self.cfg.seed, "mismatch-side", year, i).random() < 0.5 else pair.b rows.append((i, side, pct)) out[year] = rows return out def _post_extra(self, ledgers: dict[str, Ledger], p: VgoPair, side: str, amount: Decimal, day: date, memo: str) -> None: if p.kind == "trade": if side == p.a: _seller_invoice(ledgers[p.a], day, p.b, amount, memo) else: _buyer_invoice(ledgers[p.b], day, p.a, amount, memo) elif side == p.a: _lender_interest(ledgers[p.a], day, p.b, amount, memo) else: _borrower_interest(ledgers[p.b], day, p.a, amount, memo) # -- помесячные операции -- def _trade_month(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: first = self.ps[0].year for p in self.trades: cur = self._amount(p, year, month) if cur <= 0: continue f = self.pay[(p.a, p.b)] paid = q(cur * f) prev_rest = D(0) py, pm = (year - 1, 12) if month == 1 else (year, month - 1) if py >= first: pv = self._amount(p, py, pm) prev_rest = pv - q(pv * f) sa, bl = ledgers[p.a], ledgers[p.b] _seller_invoice(sa, date(year, month, 10), p.b, cur, "реализация (ВГО)") _buyer_invoice(bl, date(year, month, 10), p.a, cur, "закупка (ВГО)") pay = paid + prev_rest if pay > 0: d20 = date(year, month, 20) sa.post(d20, "51", None, "62.01", An(cp=p.b), pay, "поступление от покупателя") bl.post(d20, "60.01", An(cp=p.a), "51", None, pay, "оплата поставщику") def _loan_month(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: day = date(year, month, 28) for p in self.loans: amount = _interest(self.principal[p.b]) if amount <= 0: continue la, bo = ledgers[p.a], ledgers[p.b] _lender_interest(la, day, p.b, amount, "проценты по займу") _borrower_interest(bo, day, p.a, amount, "проценты по займу") la.post(day, "51", None, "76.09", An(cp=p.b), amount, "получение процентов") bo.post(day, "76.09", An(cp=p.a), "51", None, amount, "уплата процентов") def _dividends(self, ledgers: dict[str, Ledger], year: int) -> None: head = self.group.members[0] for child in self.group.members[1:]: total = q(self.models[child.code].rev_year * DIVIDEND_SHARE) part = _head_part(child, total) c, h = ledgers[child.code], ledgers[head.code] d10, d20 = date(year, DIVIDEND_MONTH, 10), date(year, DIVIDEND_MONTH, 20) if part > 0: c.post(d10, "84", None, "76.03", An(cp=head.code), part, "объявлены дивиденды") h.post(d10, "76.03", An(cp=child.code), "91.01", An(cp=child.code, item="Дивиденды"), part, "дивиденды дочерней") c.post(d20, "76.03", An(cp=head.code), "51", None, part, "выплата дивидендов") h.post(d20, "51", None, "76.03", An(cp=child.code), part, "получение дивидендов") rest = total - part if rest > 0 and child.minority is not None: an = An(cp=child.minority.inn) c.post(d10, "84", None, "76.03", an, rest, "объявлены дивиденды") c.post(d20, "76.03", an, "51", None, rest, "выплата дивидендов") def _mismatches(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: # создание: последний месяц первого периода года fp = self.first_period.get(year) if fp is not None and fp.end.month == month: at = last_day(year, month) for i, side, pct in self.plan_mm.get(year, []): p = self.pairs[i] ba, bb = pair_balance(ledgers, p, at) base = min(abs(ba), abs(bb)) amount = q(base * pct / 100) if amount <= 0: raise ValueError(f"пара {p.a}-{p.b} ({p.kind}): нулевое сальдо {base} на {at}, расхождения нет") memo = "накладная в пути" if p.kind == "trade" else "доначисление процентов (в пути)" self._post_extra(ledgers, p, side, amount, date(year, month, 25), memo) self.found.setdefault(i, {})[year] = Mismatch(side, year, fp.key, amount, pct, pct > THRESHOLD) # снятие: 31 января следующего года, если январь внутри книги if month == 1: for i, by_year in sorted(self.found.items()): mm = by_year.get(year - 1) if mm is not None: p = self.pairs[i] other = p.b if mm.side == p.a else p.a memo = "накладная дошла" if p.kind == "trade" else "доначисление процентов получено" self._post_extra(ledgers, p, other, mm.amount, date(year, 1, 31), memo) def extra(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: if not self.models: self._init_terms(ledgers) self._trade_month(ledgers, year, month) self._loan_month(ledgers, year, month) if month == DIVIDEND_MONTH: self._dividends(ledgers, year) self._mismatches(ledgers, year, month) def result_pairs(self) -> list[VgoPair]: out = [] for i, p in enumerate(self.pairs): mms = tuple(v for _, v in sorted(self.found.get(i, {}).items())) out.append(VgoPair(p.a, p.b, p.kind, mms[0] if mms else None, mms)) return out def build(cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan) -> tuple[dict[str, Ledger], list[VgoPair]]: """Полные книги группы: внешняя экономика + операции внутри группы + расхождения ВГО; и итоговый список пар.""" b = _Builder(cfg, group, ps) ledgers = build_ledgers(cfg, group, ps, plan, extra=b.extra, opening_hook=_opening_hook(cfg, group)) return ledgers, b.result_pairs()