355 lines
18 KiB
Python
355 lines
18 KiB
Python
"""Операции внутри группы и расхождения ВГО (спека §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") # закупки у группы не больше доли годовой выручки покупателя (с НДС)
|
||
SELLER_CAP = D("0.40") # ВГО-выручка продавца не больше доли его годовой выручки (с НДС), решение 32
|
||
|
||
|
||
@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 a in sorted({p.a for p in self.trades}):
|
||
tot = sum((v for (aa, _), v in raw.items() if aa == a), D(0))
|
||
cap = self.models[a].rev_year * (1 + D("0.2")) * SELLER_CAP
|
||
if tot > cap:
|
||
for key in raw:
|
||
if key[0] == a:
|
||
raw[key] = raw[key] * cap / tot
|
||
for b in sorted({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:
|
||
ledgers[p.a].post(day, "58.03", An(cp=p.b), "51", None, amount, memo)
|
||
else:
|
||
ledgers[p.b].post(day, "51", None, loan_account(self.cfg, p.b), An(cp=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 "транш в пути"
|
||
if p.kind == "loan" and side == p.a and ledgers[p.a].balance(at, "51").get(("51", None), D(0)) < 2 * amount:
|
||
side = p.b # у займодавца не хватает денег на транш: «в пути» у заёмщика (решение 32)
|
||
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()
|