From 0746623724895eff4d22abb58a670ae20e2bd3d5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 23:57:33 +0400 Subject: [PATCH] =?UTF-8?q?synthgen:=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=92?= =?UTF-8?q?=D0=93=D0=9E=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../specs/2026-11-12-synthgen-design.md | 6 + synthgen/economy.py | 27 +- synthgen/intragroup.py | 343 ++++++++++++++++++ synthgen/tests/test_intragroup.py | 172 +++++++++ 4 files changed, 543 insertions(+), 5 deletions(-) create mode 100644 synthgen/intragroup.py create mode 100644 synthgen/tests/test_intragroup.py diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index 9b61e11..552b715 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -287,3 +287,9 @@ README прогона, точные субсчета внутри перечис | 23 | 12.11.2026 | В `chart.ACCOUNTS` добавлены 01.01, 67.01, 67.02; названия всех субсчетов сверены с seed arm-core (`api/seed/core/chart_of_accounts.csv`) — расхождений нет (T1.7) | экономика использует 01.01 и кредиты/проценты 67.01/67.02, которых не было в копии #35 | нет | | 24 | 12.11.2026 | Поведение контрагентов: ~72 % «обычные» (оплата в следующем месяце), ~20 % «только обороты» (оплата в месяце), ~5 % «только сальдо» (входящий остаток), ~3 % «аванс» (62.02/60.02 обратного знака, не меньше одного при n ≥ 8); трое крупнейших — всегда обычные. Для 58 «обычные» и «только сальдо» — остаток займа 58.03, «только обороты» — выдан и возвращён в месяце. Без внешних контрагентов (mean = 0) операции идут без аналитики контрагента | §5.3 задаёт доли, но не привязку к субсчетам; механика — из #35 | доли в ОСВ могут отличаться от реальной выгрузки банка — поменять константы в `economy._classes` | | 25 | 12.11.2026 | Головная в `economy` — холдинг: выручка ~50 млн ₽/год, ОС 1.5× выручки, закупок 10 %; вложений 58.01 и участников в 80 нет — 80 проводится без аналитики на 2 % годовой выручки (их добавит Task 4 вместе с операциями внутри группы) | вложения 58.01 по смыслу — операции внутри группы (гудвилл 58.01 ↔ 80 дочерних), не внешняя экономика | Task 4 должен перепровести 80 по участникам и 58.01 головной | +| 26 | 12.11.2026 | Входящие остатки групповых операций (Task 4): капитал дочерней — 80.09 по участникам (головная `An(cp=<код>)`, миноритарий `An(cp=<ИНН>)`, доли по `head_share`), у головной 58.01 по каждой дочерней на ту же сумму; займы головной — 58.03 ↔ 66.03 или 67.03 (по зерну `loan-term`) на 3–6 % годовой выручки дочерней; 84 пересчитывается из итога строк после хука (`economy.open_books(opening_hook=…)`). Собственный 80 головной остаётся без аналитики | §5.4 говорит «80», бриф — «80.09»; по ДП §4 правило гудвилла берёт 80/83, 80.09 входит; участники головной в группе неизвестны | если ядру нужен 80 (без .09) — сменить субсчёт в `_opening_hook` | +| 27 | 12.11.2026 | `VgoPair` помимо `mismatch` получил `mismatches` (по одному на год, где пара выбрана); `mismatch` — расхождение первого года | §5.5 выбирает пары заново каждый год, одним полем не выразить | потребители читают `mismatches`; `mismatch` — только для удобства | +| 28 | 12.11.2026 | Торговля: объём пары 5–20 % выручки продавца (с НДС), но суммарные закупки покупателя у группы ≤ 10 % его годовой выручки (пропорциональное сжатие); счёт в день 10, оплата 50–90 % в месяце счёта, остаток — в следующем; себестоимость продавца по ВГО-реализации не проводится, покупатель списывает товар (90.02 Кт 41) | без ограничения покупатель с 7 поставщиками уходил в минус по 51 (проверяется тестом); себестоимость продавца потребовала бы остатков 41 | прибыль продавца по ВГО завышена на себестоимость; объём при многих продавцах меньше 5 % | +| 29 | 12.11.2026 | Расхождение: процент малых — 1.05–3.95, крупных — 8.05–24.95 (первые `k//2` выбранных малые); проводка «в пути» — день 25, снятие — 31 января; у торговли — накладная продавца (Дт 62.01 Кт 90.01) или покупателя (Дт 41 Кт 60.01), у займа — доначисление процентов (Дт 76.09 Кт 91.01 / Дт 91.02 Кт 76.09). `pct` считается от сальдо на конец периода создания; в следующих периодах сальдо торговли меняется, сумма разницы остаётся | границы с запасом от 1/4/8/25 %, чтобы округление суммы не выводило процент из диапазона; бриф требует проценты как причину расхождения | для займа процент относится к основному долгу, не к процентам | +| 30 | 12.11.2026 | Дивиденды: май каждого года книги, 3 % годовой выручки дочерней; доля головной по `head_share` (Дт 84 Кт 76.03 `An(cp=<код>)` ↔ Дт 76.03 Кт 91.01 item «Дивиденды»), остаток — миноритарию через `An(cp=<ИНН>)`; выплата в день 20 того же месяца | решение 11 требует нулевого сальдо 76.03; миноритарий получает свою долю | если нужны дивиденды из прибыли года — привязать к 84 | +| 31 | 12.11.2026 | Проценты по займам группы — 10 % годовых, начисляются и уплачиваются 28-го числа каждого месяца по субсчёту 76.09 обеих сторон | пары 76↔76 по правилу 6 ДП §4, остаток 76.09 нулевой | нет | diff --git a/synthgen/economy.py b/synthgen/economy.py index 98bef36..b827131 100644 --- a/synthgen/economy.py +++ b/synthgen/economy.py @@ -227,23 +227,39 @@ def opening_lines(m: CompanyModel) -> list[tuple[str, An | None, Decimal]]: for i, a in enumerate(_split_all(fm, m.rev_year * D(share))): if a > 0: lines.append((f"{acct}.01", fm.ans[i], -a)) - lines.append(("80", None, -q(m.rev_year * D("0.02")))) + lines.append(("80", None, -capital_of(m))) return lines +def capital_of(m: CompanyModel) -> Decimal: + """Уставный капитал компании на открытии (2 % годовой выручки).""" + return q(m.rev_year * D("0.02")) + + def _split_all(fm: FileModel, total: Decimal) -> list[Decimal]: return [q(total * w / fm.w_all) for w in fm.w] -def open_books(cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan) -> dict[str, Ledger]: - """Книги членов группы с входящими остатками на `book_start(ps)`; 84 — балансирующая строка.""" +OpeningHook = Callable[[Member, dict[str, "CompanyModel"], list[tuple[str, An | None, Decimal]]], list[tuple[str, An | None, Decimal]]] + + +def open_books( + cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan, opening_hook: OpeningHook | None = None +) -> dict[str, Ledger]: + """Книги членов группы с входящими остатками на `book_start(ps)`; 84 — балансирующая строка. + + `opening_hook(member, модели всех членов, строки)` может заменить/добавить строки (операции внутри группы, Task 4); + 84 считается после него, поэтому книга остаётся сбалансированной.""" day = book_start(ps) ledgers: dict[str, Ledger] = {} + models = {mb.code: _build_model(cfg, plan, mb, ps[0].year) for mb in group.members} for member in group.members: - model = _build_model(cfg, plan, member, ps[0].year) + model = models[member.code] lg = Ledger(member.code) lg.meta["model"] = model lines = opening_lines(model) + if opening_hook is not None: + lines = opening_hook(member, models, lines) total = sum((v for _, _, v in lines), D(0)) if total <= 0: raise ValueError(f"{member.code}: активы не больше обязательств, 84 стал бы дебетовым") @@ -425,9 +441,10 @@ def build_ledgers( ps: list[Period], plan: CounterpartyPlan, extra: Callable[[dict[str, Ledger], int, int], None] | None = None, + opening_hook: OpeningHook | None = None, ) -> dict[str, Ledger]: """Книги всех членов: входящие остатки + помесячно (внешние операции, `extra`, закрытие).""" - ledgers = open_books(cfg, group, ps, plan) + ledgers = open_books(cfg, group, ps, plan, opening_hook) for year, month in months(ps): post_month(ledgers, cfg, group, plan, year, month) if extra is not None: diff --git a/synthgen/intragroup.py b/synthgen/intragroup.py new file mode 100644 index 0000000..7d954bd --- /dev/null +++ b/synthgen/intragroup.py @@ -0,0 +1,343 @@ +"""Операции внутри группы и расхождения ВГО (спека §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() diff --git a/synthgen/tests/test_intragroup.py b/synthgen/tests/test_intragroup.py new file mode 100644 index 0000000..bdbc205 --- /dev/null +++ b/synthgen/tests/test_intragroup.py @@ -0,0 +1,172 @@ +import math +from datetime import date +from decimal import Decimal + +import pytest + +from synthgen import dictionary +from synthgen.config import Config +from synthgen.counterparties import plan as make_plan +from synthgen.economy import last_day, months +from synthgen.group import build_group +from synthgen.intragroup import build, pair_balance, plan_pairs +from synthgen.periods import book_start, build_periods + +D = Decimal + + +def _make(**kw): + mean = kw.pop("mean", 10) + cfg = Config(group_size=kw.pop("group_size", 5), periods=kw.pop("periods", 6), osv_counterparties_mean=mean, **kw) + g = build_group(cfg, dictionary.load()) + ps = build_periods(cfg.start, cfg.periods) + books, pairs = build(cfg, g, ps, make_plan(cfg, g)) + return cfg, g, ps, books, pairs + + +@pytest.fixture(scope="module") +def built(): + return _make(vgo_mismatch_share=D("0.3")) + + +def test_plan_pairs_deterministic_and_loans(): + cfg = Config(group_size=6) + g = build_group(cfg, dictionary.load()) + p1, p2 = plan_pairs(cfg, g), plan_pairs(cfg, g) + assert p1 == p2 + loans = [p for p in p1 if p.kind == "loan"] + assert [p.b for p in loans] == [m.code for m in g.members[1:]] + assert all(p.mismatch is None for p in p1) and all(p.a == "C01" for p in loans) + + +def test_pairs_mirror(built): + cfg, g, ps, books, pairs = built + clean = [p for p in pairs if not p.mismatches] + assert clean + for pair in clean: + for per in ps: + ba, bb = pair_balance(books, pair, per.end) + assert ba == -bb, (pair, per.key) + assert any(pair_balance(books, p, ps[0].end)[0] != 0 for p in clean if p.kind == "trade") + assert any(pair_balance(books, p, ps[0].end)[0] != 0 for p in clean if p.kind == "loan") + + +def _years(ps): + return sorted({p.year for p in ps}) + + +def test_mismatch_share(): + cfg, g, ps, books, pairs = _make(group_size=10, vgo_mismatch_share=D("0.1"), mean=5) + n = len(pairs) + for y in _years(ps): + got = sum(1 for p in pairs for m in p.mismatches if m.year == y) + assert got == max(1, math.floor(D("0.1") * n)), y + cfg0, g0, ps0, books0, pairs0 = _make(group_size=10, vgo_mismatch_share=D("0"), mean=5, periods=2) + assert all(not p.mismatches and p.mismatch is None for p in pairs0) + + +def test_mismatch_mix(built): + cfg, g, ps, books, pairs = built + for y in _years(ps): + mms = [m for p in pairs for m in p.mismatches if m.year == y] + assert len(mms) >= 2 + small = [m for m in mms if D(1) <= m.pct <= D(4)] + large = [m for m in mms if D(8) <= m.pct <= D(25)] + assert len(small) == len(mms) // 2 + assert len(large) == len(mms) - len(mms) // 2 + for m in mms: + assert m.above_threshold == (m.pct > 5) + + +def test_mismatch_lifecycle(built): + cfg, g, ps, books, pairs = built + checked_removal = 0 + for pair in pairs: + for m in pair.mismatches: + for per in ps: + if per.year != m.year or per.key < m.period_key: + continue + ba, bb = pair_balance(books, pair, per.end) + diff = (ba + bb) # зеркальные стороны: сумма = разница по модулю со знаком стороны + assert abs(diff) == m.amount, (pair, per.key) + # у стороны с проводкой модуль сальдо больше + with_entry = ba if m.side == pair.a else bb + other = bb if m.side == pair.a else ba + assert abs(with_entry) - abs(other) == m.amount + # pct — от меньшей стороны на конец периода расхождения (в следующих периодах сальдо торговли иное) + if per.key == m.period_key: + assert abs(m.pct - (m.amount / abs(other) * 100)) < D("0.01") + jan = date(m.year + 1, 1, 31) + if jan <= ps[-1].end: + ba, bb = pair_balance(books, pair, jan) + assert ba == -bb + checked_removal += 1 + assert checked_removal > 0 + + +def test_dividends_no_balance(built): + cfg, g, ps, books, pairs = built + codes = {m.code for m in g.members} + for lg in books.values(): + for per in ps: + assert lg.balance(per.end, "76.03") == {} + head = books["C01"] + t = head.turnover(date(2025, 4, 1), date(2025, 6, 30), "76.03") + assert t and all(dt > 0 and ct > 0 for dt, ct in t.values()) + for code in codes - {"C01"}: + t = books[code].turnover(date(2025, 4, 1), date(2025, 6, 30), "76.03") + assert t and any(ct > 0 for dt, ct in t.values()) + + +def test_shares(built): + cfg, g, ps, books, pairs = built + start = book_start(ps) + head = books["C01"] + h58 = {an.cp: v for (a, an), v in head.balance(start, "58.01").items()} + assert set(h58) == {m.code for m in g.members[1:]} + for m in g.members[1:]: + c80 = {an.cp: v for (a, an), v in books[m.code].balance(start, "80.09").items()} + assert c80[head.company] == -h58[m.code] + if m.minority is None: + assert set(c80) == {"C01"} and m.head_share == D("1.00") + else: + assert set(c80) == {"C01", m.minority.inn} + assert c80[m.minority.inn] < 0 + # миноритарий не попал к головной + assert not any(an.cp in {m.minority.inn for m in g.members if m.minority} for (_, an) in head.balance(start, "58") if an) + assert any(m.minority for m in g.members) + + +def test_books_still_balanced(built): + cfg, g, ps, books, pairs = built + start = book_start(ps) + for lg in books.values(): + for day in (start, date(2025, 3, 31), date(2025, 12, 31), ps[-1].end): + assert sum(lg.balance(day).values()) == 0 + assert lg.balance(day, "000") == {} + assert not [p for p in lg.postings if "000" in (p.dt, p.ct) and p.day > start] + for per in ps: + for acct in ("90", "91"): + assert lg.balance(per.end, acct) == {} + for y, mth in months(ps): + assert lg.balance(last_day(y, mth), "51").get(("51", None), D(0)) >= 0, (lg.company, y, mth) + + +def test_group_analytics_not_mixed(built): + cfg, g, ps, books, pairs = built + codes = {m.code for m in g.members} + ext = {o.inn for o in g.externals} + for lg in books.values(): + for (acct, an) in lg.balance(ps[-1].end): + if an is not None and an.cp in codes: + assert acct.split(".")[0] in {"58", "60", "62", "66", "67", "76", "80", "91"} + if acct.startswith(("60", "62")) and an is not None and an.cp in ext: + assert an.cp not in codes + + +def test_edge_group_two(): + cfg, g, ps, books, pairs = _make(group_size=2, periods=4, vgo_mismatch_share=D("0.5")) + assert [p.kind for p in pairs if p.kind == "loan"] == ["loan"] + for lg in books.values(): + assert sum(lg.balance(ps[-1].end).values()) == 0 + assert any(p.mismatches for p in pairs)