synthgen: расхождения по займам на основном долге, потолок ВГО-выручки продавца, тесты (#38)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0746623724
commit
8f1b4a7312
@ -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 % сжимает объём пар |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
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"}
|
||||
if acct.startswith(("60", "62")) and an is not None and an.cp in ext:
|
||||
assert an.cp not 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():
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user