arm-ka-fixtures/synthgen/intragroup.py
Andrey Epifancev 0746623724 synthgen: операции внутри группы и расхождения ВГО (#38)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 23:57:33 +04:00

344 lines
17 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.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()