synthgen: операции внутри группы и расхождения ВГО (#38)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 23:57:33 +04:00
parent f0f60b1839
commit 0746623724
4 changed files with 543 additions and 5 deletions

View File

@ -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 нулевой | нет |

View File

@ -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:

343
synthgen/intragroup.py Normal file
View File

@ -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()

View File

@ -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)