diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index 552b715..9920963 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -290,6 +290,7 @@ README прогона, точные субсчета внутри перечис | 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 %, чтобы округление суммы не выводило процент из диапазона; бриф требует проценты как причину расхождения | для займа процент относится к основному долгу, не к процентам | +| 29 | 12.11.2026 | Расхождение: процент малых — 1.05–3.95, крупных — 8.05–24.95 (первые `k//2` выбранных малые); проводка «в пути» — день 25, снятие — 31 января; у торговли — накладная продавца (Дт 62.01 Кт 90.01) или покупателя (Дт 41 Кт 60.01), у займа — транш по основному долгу (Дт 58.03 Кт 51 у займодавца либо Дт 51 Кт 66.03/67.03 у заёмщика; R-3, решение 32). `pct` считается от меньшей стороны сальдо на конец периода создания; в следующих периодах сальдо торговли меняется, сумма разницы остаётся | границы с запасом от 1/4/8/25 %, чтобы округление суммы не выводило процент из диапазона | пересчёт процента ядром по другому правилу даст иной pct | | 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 нулевой | нет | +| 32 | 12.11.2026 | R-3 (рулинг контроллера по ревью Task 4): расхождение по займу — по основному долгу (правило 4 ДП §4), проценты 76.09 ↔ 76.09 начисляются симметрично и гасятся в месяце начисления, в расхождения не попадают; если у займодавца на конец периода денег меньше 2 × суммы транша, транш «в пути» проводит заёмщик. ВГО-выручка продавца за год ≤ 40 % его годовой выручки с НДС (`SELLER_CAP`, симметрично `BUYER_CAP`; пропорциональное сжатие) | расхождение в 76↔76 дало бы ядру правило 6 с меньшей стороной 0 (процент не определён, OQ-175); без потолка продавца ВГО-выручка могла превысить годовую выручку | при другом проценте ядра у займов оракул Task 5 расходится с генератором; потолок 40 % сжимает объём пар | diff --git a/synthgen/intragroup.py b/synthgen/intragroup.py index 7d954bd..2489e7e 100644 --- a/synthgen/intragroup.py +++ b/synthgen/intragroup.py @@ -6,7 +6,8 @@ - торговля: 62.01 продавца ↔ 60.01 покупателя, оплата частично (остаток на конец периода — сальдо пары); - дивиденды: во 2 квартале (май), объявлены и выплачены в одном месяце (решение 11): сальдо 76.03 на конец периода 0; - расхождения: в последнем месяце первого периода года одна сторона проводит «накладную в пути» (торговля) или - доначисление процентов (заём), вторая проводит её 31 января следующего года (если январь есть в книге, решение 13). + транш «в пути» по основному долгу (заём; проценты — симметрично, расхождений не дают), вторая проводит её 31 января + следующего года (если январь есть в книге, решение 13). Аналитика группы — `An(cp=<код члена>)`; внешние контрагенты в 60/62 остаются с `An(cp=<ИНН>)`. """ @@ -38,6 +39,7 @@ 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) @@ -192,7 +194,14 @@ class _Builder: 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}: + 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) @@ -237,9 +246,9 @@ class _Builder: 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) + ledgers[p.a].post(day, "58.03", An(cp=p.b), "51", None, amount, memo) else: - _borrower_interest(ledgers[p.b], day, p.a, amount, memo) + 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: @@ -306,7 +315,9 @@ class _Builder: 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 "доначисление процентов (в пути)" + 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 января следующего года, если январь внутри книги @@ -316,7 +327,7 @@ class _Builder: 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 "доначисление процентов получено" + 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: diff --git a/synthgen/tests/test_intragroup.py b/synthgen/tests/test_intragroup.py index bdbc205..503ac02 100644 --- a/synthgen/tests/test_intragroup.py +++ b/synthgen/tests/test_intragroup.py @@ -156,12 +156,74 @@ 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} + seen_group = seen_ext = False 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 + for acct, an in lg.balance(ps[-1].end): + if an is None or not an.cp: + continue + if acct.startswith(("60", "62")): + assert an.cp in codes or an.cp in ext, (acct, an) + seen_group |= an.cp in codes + seen_ext |= an.cp in ext + assert not (an.cp in codes and an.cp in ext) + assert seen_group and seen_ext + for lg in books.values(): + for p in lg.postings: + for acct, an in ((p.dt, p.dt_an), (p.ct, p.ct_an)): + if an is not None and an.cp in codes: + assert acct.split(".")[0] in {"58", "60", "62", "66", "67", "76", "80", "91"}, acct + + +def test_loan_mismatch_on_principal(built): + cfg, g, ps, books, pairs = built + loans = [p for p in pairs if p.kind == "loan" and p.mismatches] + assert loans + for p in loans: + for m in p.mismatches: + memos = {x.memo for x in books[p.a].postings + books[p.b].postings if x.memo == "транш в пути"} + assert memos == {"транш в пути"} + ba = _sum_loan(books[p.a], m, "58.03", p.b) + assert ba != 0 + for per in ps: # проценты не копятся: 76.09 на конец любого периода = 0 + assert books[p.a].balance(per.end, "76.09") == {} and books[p.b].balance(per.end, "76.09") == {} + + +def _sum_loan(lg, m, acct, cp): + return sum(v for (a, an), v in lg.balance(date(m.year, 12, 31), acct).items() if an is not None and an.cp == cp) + + +def test_seller_cap(built): + cfg, g, ps, books, pairs = built + codes = {m.code for m in g.members} + for code, lg in books.items(): + rev = lg.meta["model"].rev_year + for year in {p.year for p in ps}: + tot = sum( + (x.amount for x in lg.postings if x.memo == "реализация (ВГО)" and x.day.year == year), + D(0), + ) + assert tot <= D("0.4") * D("1.2") * rev * D("1.1") + D("0.01"), (code, year) + assert any(x.memo == "реализация (ВГО)" for lg in books.values() for x in lg.postings) + + +def test_build_deterministic(): + r1 = _make(vgo_mismatch_share=D("0.3"), periods=4) + r2 = _make(vgo_mismatch_share=D("0.3"), periods=4) + assert r1[4] == r2[4] + for code in r1[3]: + assert r1[3][code].postings == r2[3][code].postings + + +def test_mismatch_odd_count(): + cfg = Config(group_size=5) + n = len(plan_pairs(cfg, build_group(cfg, dictionary.load()))) + share = D("3.5") / n # k = floor(3.5) = 3: одна малая, две крупные + cfg, g, ps, books, pairs = _make(group_size=5, vgo_mismatch_share=share, periods=2) + for y in _years(ps): + mms = [m for p in pairs for m in p.mismatches if m.year == y] + assert len(mms) == 3 + assert sum(1 for m in mms if m.pct < 5) == 1 + assert sum(1 for m in mms if m.pct > 5) == 2 def test_edge_group_two():