synthgen: периоды, внешние контрагенты, экономика компаний (#38)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 23:49:07 +04:00
parent f215bab9b0
commit 418f066bd8
9 changed files with 776 additions and 9 deletions

View File

@ -282,3 +282,8 @@ README прогона, точные субсчета внутри перечис
| 18 | 12.11.2026 | Вариантное написание члена группы в ОСВ без ИНН: перестановка `<корень> <prefix> <форма>` без кавычек (`group.variant_spelling`); вариант выбирает `rng_for(seed, "group", prefix)` среди остальных членов | нужно написание, которое не совпадёт с `name` и `name_1c` посимвольно, но читается человеком; точный механизм сопоставления ядра по имени не проверялся | если ядро нормализует порядок слов и сопоставит автоматически — ручного сопоставления не будет, поменять правило варианта |
| 19 | 12.11.2026 | Доли головной: дочерняя без миноритария — `1.00`; у 1–2 дочерних (при `group_size=2` — у единственной) доля 0.51–0.99, миноритарий — внешняя организация словаря (юрлицо не банк) | §4 тестов требует `head_share ∈ [0.51, 1.00]` и миноритария у 1–2; миноритарий остаётся внешним контрагентом, источник долей — только данные группы | миноритарий не как контрагент в ОСВ — не будет связи, исключить из `externals` |
| 20 | 12.11.2026 | В ЕЦП «Базовый РФ ГК в CRM» — `Ростовский региональный филиал`, `Тип` — `ЮЛ`, как в #35; имя группы в колонке «Группа компаний» — `ГК <prefix>` (в #35 было без «ГК») | импорт ядра принимает любые значения (обязательны ИНН, iD ГК, Группа компаний, Организация, Тип); `ГК <prefix>` задано §4.2 | нет |
| 21 | 12.11.2026 | Веса контрагентов — детерминированная кривая Лоренца Парето (доля верхних p = p^(1−1/α), α=1.2, шум ±15 %), а не выборка `paretovariate` | выборка Парето на 43 контрагентах давала долю верхних 10 % от 23 % до 97 % по зерну; тест формы (≥ 50 %) стал бы случайным | распределение чуть «глаже» настоящей выборки Парето |
| 22 | 12.11.2026 | `Ledger`: индекс накопленных итогов по (счёт, аналитика) для `balance` на дату не раньше последней проводки; `Posting`/`An` — `slots=True` | закрытие месяца спрашивает сальдо по 90/91/68/99 после каждого месяца; полный проход по проводкам давал O(N²) на крупном прогоне (600 тыс. проводок при 15 компаниях, 6 периодах, 150 контрагентах) | запрос сальдо на прошлую дату всё ещё идёт проходом по проводкам (кэшируется) |
| 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 головной |

View File

@ -3,6 +3,7 @@
ACCOUNTS: dict[str, str] = {
"000": "Вспомогательный счет",
"01": "Основные средства",
"01.01": "Основные средства в организации",
"02": "Амортизация основных средств",
"03": "Доходные вложения в материальные ценности",
"08": "Вложения во внеоборотные активы",
@ -28,6 +29,8 @@ ACCOUNTS: dict[str, str] = {
"66.02": "Проценты по краткосрочным кредитам",
"66.03": "Краткосрочные займы",
"66.04": "Проценты по краткосрочным займам",
"67.01": "Долгосрочные кредиты",
"67.02": "Проценты по долгосрочным кредитам",
"67.03": "Долгосрочные займы",
"67.04": "Проценты по долгосрочным займам",
"68": "Расчеты по налогам и сборам",

View File

@ -0,0 +1,76 @@
"""Внешние контрагенты в ОСВ (спека §5.3): сколько и кто, для каждой пары (компания, счёт).
Число `n` — отрицательное биномиальное (гамма-пуассоновская смесь с дисперсией ≈ 2 × матожидание), матожидание
`mean × w(счёт) / среднее_w`, так что среднее `n` по файлам счетов 58/60/62/66/67/76 ≈ `osv_counterparties_mean`.
Потолки: 58/60/62/76 — словарь без банков и компаний группы, 66/67 — банки. Недобор из-за потолка перераспределяется на
остальные файлы того же счёта (решение 9); если потолок всё же срезал выборку — `capped=True`.
"""
import math
import random
from dataclasses import dataclass, field
from synthgen.config import Config
from synthgen.dictionary import Org
from synthgen.group import Group
from synthgen.rng import rng_for
OSV_ACCOUNTS = ("01", "58", "60", "62", "66", "67", "76", "80", "90")
WEIGHTS = {"60": 1.6, "62": 1.6, "76": 1.0, "66": 0.1, "67": 0.05, "58": 0.1}
BANK_ACCOUNTS = ("66", "67")
@dataclass(frozen=True)
class CounterpartyPlan:
by_file: dict[tuple[str, str], list[Org]] = field(default_factory=dict) # (код компании, корень счёта) → контрагенты
capped: bool = False # потолок словаря срезал выборку хотя бы в одном файле
def _poisson(rng: random.Random, lam: float) -> int:
if lam <= 0:
return 0
if lam > 30: # нормальное приближение: точное для нашей задачи, а цикл Кнута на больших λ медленный
return max(0, round(rng.gauss(lam, math.sqrt(lam))))
limit, k, p = math.exp(-lam), 0, 1.0
while True:
p *= rng.random()
if p <= limit:
return k
k += 1
def _draw_n(rng: random.Random, mu: float) -> int:
"""Отрицательное биномиальное с матожиданием `mu` и дисперсией 2·mu: Poisson(Gamma(mu, 1))."""
if mu <= 0:
return 0
return _poisson(rng, rng.gammavariate(mu, 1.0))
def plan(cfg: Config, group: Group) -> CounterpartyPlan:
codes = [m.code for m in group.members]
avg_w = sum(WEIGHTS.values()) / len(WEIGHTS)
capped = False
by_file: dict[tuple[str, str], list[Org]] = {}
for acct, w in WEIGHTS.items():
pool = group.banks if acct in BANK_ACCOUNTS else group.externals
cap = len(pool)
mu = cfg.osv_counterparties_mean * w / avg_w
want = [_draw_n(rng_for(cfg.seed, "cp-count", code, acct), mu) for code in codes]
got = [min(n, cap) for n in want]
shortfall = sum(want) - sum(got)
if shortfall > 0:
capped = True
room = [i for i, n in enumerate(got) if n < cap]
while shortfall > 0 and room: # круговое доведение недобора до потолка файлов, где есть место
nxt = []
for i in room:
if shortfall == 0:
break
got[i] += 1
shortfall -= 1
if got[i] < cap:
nxt.append(i)
room = nxt
for code, n in zip(codes, got):
by_file[(code, acct)] = rng_for(cfg.seed, "cp-set", code, acct).sample(pool, n) if n else []
return CounterpartyPlan(by_file=by_file, capped=capped)

416
synthgen/economy.py Normal file
View File

@ -0,0 +1,416 @@
"""Экономика компаний группы с внешними контрагентами (спека §5.2–5.3).
Источник образца потоков и закрытия месяца: synthetic-group/gen/economy.py (#35); контрагенты здесь — из плана
`counterparties.plan`, а не фиксированные SUP_A/BUY_G.
Порядок построения (на него опирается Task 4, операции внутри группы):
open_books(...) входящие остатки на book_start (31.12 перед первым периодом);
для каждого месяца: post_month(...) внешние операции месяца,
hook(...) операции внутри группы (extra),
close_month(...) закрытие 26/44 → 90, 90/91 → 99, налог, в декабре 99 → 84.
`build_ledgers` — эта последовательность целиком.
Аналитика контрагента: `An(cp=<ИНН Org>)` для внешних, `An(cp=<код члена>)` для компаний группы (последние — Task 4).
Модель компании (веса, классы контрагентов, профиль) строится один раз в `open_books` и лежит в `Ledger.meta["model"]`.
"""
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
import math
from synthgen.config import Config
from synthgen.counterparties import CounterpartyPlan
from synthgen.group import Group, Member
from synthgen.ledger import CENT, An, Ledger, opening
from synthgen.periods import Period, book_end, book_start
from synthgen.rng import rng_for
D = Decimal
VAT = D("0.2")
PROFIT_TAX = D("0.2")
SOCIAL = D("0.3")
GROWTH = D("0.07") # рост выручки в год
FNS = An("FNS")
PARETO_ALPHA = 1.2
# kind словаря → (доля закупок товара к выручке, доля расходов на продажу, доля зарплаты, ОС к годовой выручке)
KIND_PROFILE: dict[str, tuple[str, str, str, str]] = {
"агро": ("0.55", "0.05", "0.08", "0.5"),
"торговля": ("0.65", "0.07", "0.06", "0.3"),
"производство": ("0.50", "0.06", "0.10", "0.5"),
"строительство": ("0.50", "0.05", "0.12", "0.5"),
"логистика": ("0.35", "0.08", "0.12", "0.5"),
"услуги": ("0.15", "0.06", "0.20", "0.3"),
}
HEAD_PROFILE = ("0.10", "0.05", "0.25", "1.5") # головная — холдинг: малая выручка, большие активы
MEDIAN_REVENUE = 1_000_000_000
HEAD_REVENUE = 50_000_000
FLOW_ACCOUNTS = ("62", "60", "76") # с потоками по контрагентам (продажи, закупки, прочие поставщики)
def q(x: Decimal) -> Decimal:
return x.quantize(CENT, rounding=ROUND_HALF_UP)
def vat_of(gross: Decimal) -> Decimal:
return q(gross * 20 / 120)
def last_day(year: int, month: int) -> date:
return date(year + (month == 12), month % 12 + 1, 1) - timedelta(days=1)
def _prev(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1)
@dataclass
class FileModel:
"""Контрагенты одного файла (компания × счёт): аналитика, веса Парето, класс поведения."""
ans: list[An | None]
w: list[Decimal] # по убыванию
cls: list[str] # normal (оплата в следующем месяце) / turn (оплата в месяце) / bal (только сальдо) / rev (аванс .02)
w_all: Decimal = D(0)
w_active: Decimal = D(0)
def __post_init__(self) -> None:
self.w_all = sum(self.w, D(0))
self.w_active = sum((w for w, c in zip(self.w, self.cls) if c in ("normal", "turn")), D(0))
@dataclass
class CompanyModel:
code: str
seed: int
first_year: int
rev_year: Decimal
cogs: Decimal
sell: Decimal
salary: Decimal
ppe_ratio: Decimal
files: dict[str, FileModel] = field(default_factory=dict)
banks: dict[str, FileModel] = field(default_factory=dict) # "66"/"67" → банки и веса долей долга
def _weights(rng, n: int) -> list[Decimal]:
"""Веса контрагентов по кривой Лоренца Парето: доля верхних p контрагентов = p^(1 − 1/α); α = 1.2 даёт
~68 % оборота у верхних 10 %. Детерминированная кривая вместо выборки Парето (иначе доля верхних 10 % прыгает
от 23 % до 97 % по зерну), небольшой шум — чтобы веса разных файлов не совпадали."""
expo = 1 - 1 / PARETO_ALPHA
shares = [((k / n) ** expo - ((k - 1) / n) ** expo) * rng.uniform(0.85, 1.15) for k in range(1, n + 1)]
return [D(max(1, round(w * 1e9))) for w in sorted(shares, reverse=True)]
def _classes(rng, acct: str, n: int) -> list[str]:
out = []
for i in range(n):
u = rng.random()
if i < 3: # крупнейшие контрагенты — обычные: оплата в следующем месяце
out.append("normal")
elif u < 0.03:
out.append("rev" if acct in ("60", "62") else "bal")
elif u < 0.08:
out.append("bal")
elif u < 0.28:
out.append("turn")
else:
out.append("normal")
if acct in ("60", "62") and n >= 8 and "rev" not in out:
out[n // 2] = "rev" # хотя бы один контрагент с обратным знаком сальдо (аванс/переплата, §5.3)
return out
def _build_file(cfg: Config, code: str, acct: str, orgs: list) -> FileModel:
n = len(orgs)
if n == 0: # нет контрагентов в плане (mean = 0): операции идут без аналитики контрагента
return FileModel([None], [D(1)], ["normal"])
ans = [An(cp=o.inn) for o in orgs]
return FileModel(ans, _weights(rng_for(cfg.seed, "cp-weights", code, acct), n), _classes(rng_for(cfg.seed, "cp-class", code, acct), acct, n))
def _build_model(cfg: Config, plan: CounterpartyPlan, member: Member, first_year: int) -> CompanyModel:
code = member.code
scale = rng_for(cfg.seed, "scale", code)
if member.is_head:
rev = HEAD_REVENUE * math.exp(scale.gauss(0, 0.5))
prof = HEAD_PROFILE
else:
rev = MEDIAN_REVENUE * math.exp(scale.gauss(0, math.log(10) / 2)) # ×10 в обе стороны на ±2σ
prof = KIND_PROFILE[member.org.kind]
rev = min(max(rev, 5e7), 2e10)
model = CompanyModel(code, cfg.seed, first_year, D(int(rev)), D(prof[0]), D(prof[1]), D(prof[2]), D(prof[3]))
for acct in ("58", *FLOW_ACCOUNTS):
model.files[acct] = _build_file(cfg, code, acct, plan.by_file.get((code, acct), []))
for acct in ("66", "67"):
orgs = plan.by_file.get((code, acct), [])
if orgs:
model.banks[acct] = _build_file(cfg, code, acct, orgs)
return model
# ---------- суммы месяца ----------
def _noise(m: CompanyModel, tag: str, year: int, month: int) -> Decimal:
return D(rng_for(m.seed, "noise", m.code, tag, year, month).randint(92, 108)) / 100
def _rev_month(m: CompanyModel, year: int, month: int) -> Decimal:
"""Чистая выручка месяца (без НДС)."""
growth = 1 + GROWTH * (year - m.first_year)
return q(m.rev_year / 12 * growth * _noise(m, "rev", year, month))
def _total(m: CompanyModel, acct: str, year: int, month: int) -> Decimal:
"""Брутто-оборот месяца по счёту расчётов: 62 — продажи, 60 — закупки товара, 76 — прочие поставщики."""
rev = _rev_month(m, year, month)
share = {"62": D(1), "60": m.cogs, "76": m.sell}[acct]
return q(rev * share * (1 + VAT))
def _split(fm: FileModel, total: Decimal, classes: tuple[str, ...]) -> list[tuple[int, Decimal]]:
"""Раскладка оборота по контрагентам указанных классов с весами Парето; нулевые суммы отбрасываются."""
w_sum = sum((w for w, c in zip(fm.w, fm.cls) if c in classes), D(0))
if w_sum == 0 or total <= 0:
return []
out = []
for i, (w, c) in enumerate(zip(fm.w, fm.cls)):
if c in classes:
a = q(total * w / w_sum)
if a > 0:
out.append((i, a))
return out
def _side(fm: FileModel, ref: Decimal, i: int, factor: str) -> Decimal:
"""Сальдо «без оборотов» контрагента: доля месячного оборота по весу среди всех контрагентов файла."""
return q(ref * fm.w[i] / fm.w_all * D(factor))
# ---------- входящие остатки ----------
def opening_lines(m: CompanyModel) -> list[tuple[str, An | None, Decimal]]:
py, pm = m.first_year - 1, 12
ppe = q(m.rev_year * m.ppe_ratio)
lines: list[tuple[str, An | None, Decimal]] = [
("01.01", None, ppe),
("02", None, -q(ppe * D("0.3"))),
("51", None, q(m.rev_year * D("0.06"))),
]
if m.cogs:
lines.append(("41", None, q(m.rev_year * m.cogs * D("0.15"))))
# 62: дебиторка — оплата прошлого месяца; bal — старая задолженность; rev — аванс (62.02, кредит)
for acct, sign, sub, sub_rev in (("62", 1, "62.01", "62.02"), ("60", -1, "60.01", "60.02"), ("76", -1, "76.05", None)):
fm = m.files[acct]
total = _total(m, acct, py, pm)
if acct != "62" and total == 0:
continue
ref = total if total else q(_rev_month(m, py, pm))
for i, a in _split(fm, total, ("normal", "turn")):
if fm.cls[i] == "normal": # оплата в следующем месяце; turn закрыт в своём месяце
lines.append((sub, fm.ans[i], sign * a))
for i, c in enumerate(fm.cls):
if c == "bal":
lines.append((sub, fm.ans[i], sign * _side(fm, ref, i, "0.5")))
elif c == "rev" and sub_rev:
lines.append((sub_rev, fm.ans[i], -sign * _side(fm, ref, i, "0.3")))
fm58 = m.files["58"]
ref58 = q(_rev_month(m, py, pm))
for i, c in enumerate(fm58.cls):
if c in ("normal", "bal") and fm58.ans[i] is not None:
lines.append(("58.03", fm58.ans[i], _side(fm58, ref58, i, "3")))
for acct, share in (("66", "0.12"), ("67", "0.08")):
fm = m.banks.get(acct)
if fm:
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"))))
return lines
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 — балансирующая строка."""
day = book_start(ps)
ledgers: dict[str, Ledger] = {}
for member in group.members:
model = _build_model(cfg, plan, member, ps[0].year)
lg = Ledger(member.code)
lg.meta["model"] = model
lines = opening_lines(model)
total = sum((v for _, _, v in lines), D(0))
if total <= 0:
raise ValueError(f"{member.code}: активы не больше обязательств, 84 стал бы дебетовым")
for acct, an, v in lines:
opening(lg, day, acct, an, v)
opening(lg, day, "84", None, -total)
ledgers[member.code] = lg
return ledgers
# ---------- операции месяца ----------
def _flow(lg: Ledger, m: CompanyModel, acct: str, year: int, month: int) -> None:
"""Продажи (62), закупки товара (60) или прочие расходы (76) месяца и оплата контрагентам."""
fm = m.files[acct]
py, pm = _prev(year, month)
d = lambda n: date(year, month, n) # noqa: E731
cur = _split(fm, _total(m, acct, year, month), ("normal", "turn"))
prev = {i: a for i, a in _split(fm, _total(m, acct, py, pm), ("normal", "turn")) if fm.cls[i] == "normal"}
vat_total = D(0)
net_total = D(0)
sub = {"62": "62.01", "60": "60.01", "76": "76.05"}[acct]
for i, gross in cur:
an = fm.ans[i]
vat = vat_of(gross)
net = gross - vat
vat_total += vat
net_total += net
if acct == "62":
lg.post(d(10), sub, an, "90.01", None, gross, "реализация")
else:
exp = "41" if acct == "60" else "44"
lg.post(d(5), exp, None, sub, an, net, "закупка")
if vat > 0:
lg.post(d(5), "19", None, sub, an, vat, "НДС по закупке")
if vat_total > 0:
if acct == "62":
lg.post(d(10), "90.03", None, "68", FNS, vat_total, "НДС с реализации")
else:
lg.post(d(5), "68", FNS, "19", None, vat_total, "НДС к вычету")
if acct == "60" and net_total > 0:
lg.post(d(5), "90.02", None, "41", None, net_total, "себестоимость")
pay_day = d(20) if acct == "62" else d(15)
for i, gross in cur:
if fm.cls[i] == "turn":
_settle(lg, acct, sub, fm.ans[i], gross, pay_day)
for i, amount in prev.items():
_settle(lg, acct, sub, fm.ans[i], amount, pay_day)
def _settle(lg: Ledger, acct: str, sub: str, an: An | None, amount: Decimal, day: date) -> None:
if acct == "62":
lg.post(day, "51", None, sub, an, amount, "поступление от покупателя")
else:
lg.post(day, sub, an, "51", None, amount, "оплата поставщику")
def _loans(lg: Ledger, m: CompanyModel, acct: str, year: int, month: int) -> None:
"""Проценты банку по кредитам: начислены и уплачены в конце месяца (остаток по .02 не копится)."""
fm = m.banks.get(acct)
if not fm:
return
share = D("0.12") if acct == "66" else D("0.08")
day = date(year, month, 28)
for i, principal in enumerate(_split_all(fm, m.rev_year * share)):
interest = q(principal * D("0.12") / 12)
if interest > 0:
an = fm.ans[i]
lg.post(day, "91.02", An(an.cp, item="Проценты"), f"{acct}.02", an, interest, "проценты по кредиту")
lg.post(day, f"{acct}.02", an, "51", None, interest, "уплата процентов")
def _issued_loans(lg: Ledger, m: CompanyModel, year: int, month: int) -> None:
"""58.03: часть контрагентов — займ выдан и возвращён в месяце (только обороты); остальные — сальдо с открытия."""
fm = m.files["58"]
amounts = _split(fm, q(_rev_month(m, year, month) * D("0.05")), ("turn",))
for i, a in amounts:
lg.post(date(year, month, 12), "58.03", fm.ans[i], "51", None, a, "выдан займ")
lg.post(date(year, month, 22), "51", None, "58.03", fm.ans[i], a, "возврат займа")
def post_month(ledgers: dict[str, Ledger], cfg: Config, group: Group, plan: CounterpartyPlan, year: int, month: int) -> None:
"""Внешние операции месяца всех членов группы (до операций внутри группы и закрытия)."""
for code in sorted(ledgers):
lg = ledgers[code]
m: CompanyModel = lg.meta["model"]
d = lambda n: date(year, month, n) # noqa: E731
for acct in ("60", "76", "62"):
if _total(m, acct, year, month) > 0:
_flow(lg, m, acct, year, month)
_issued_loans(lg, m, year, month)
sal = q(_rev_month(m, year, month) * m.salary)
if sal > 0:
soc = q(sal * SOCIAL)
lg.post(d(25), "26", None, "70", None, sal, "начислена зарплата")
lg.post(d(25), "26", None, "69", None, soc, "страховые взносы")
lg.post(d(26), "70", None, "51", None, sal, "выплата зарплаты")
lg.post(d(26), "69", None, "51", None, soc, "уплата взносов")
lg.post(d(28), "26", None, "02", None, q(m.rev_year * m.ppe_ratio / 120), "амортизация")
_loans(lg, m, "66", year, month)
_loans(lg, m, "67", year, month)
def _close_all(lg: Ledger, day: date, prefix: str) -> Decimal:
"""Закрывает все субсчета prefix на 99; возвращает итог (Кт > 0 — прибыль)."""
result = D(0)
for (acct, an), bal in sorted(lg.balance(day, prefix).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
if bal > 0:
lg.post(day, "99", None, acct, an, bal, "закрытие на 99")
else:
lg.post(day, acct, an, "99", None, -bal, "закрытие на 99")
result -= bal
return result
def close_month(ledgers: dict[str, Ledger], year: int, month: int) -> None:
day = last_day(year, month)
for code in sorted(ledgers):
lg = ledgers[code]
vat_due = -lg.balance(day, "68").get(("68", FNS), D(0))
if vat_due > 0:
lg.post(day, "68", FNS, "51", None, vat_due, "уплата НДС")
for cost, target in (("26", "90.08"), ("44", "90.07")):
for (acct, an), bal in lg.balance(day, cost).items():
if bal > 0:
lg.post(day, target, None, acct, an, bal, "закрытие затрат")
result = _close_all(lg, day, "90") + _close_all(lg, day, "91")
tax = q(result * PROFIT_TAX)
if tax > 0:
lg.post(day, "99", None, "68.04", FNS, tax, "налог на прибыль")
elif tax < 0:
lg.post(day, "68.04", FNS, "99", None, -tax, "корректировка налога на прибыль")
if month % 3 == 0:
due = -lg.balance(day, "68.04").get(("68.04", FNS), D(0))
if due > 0:
lg.post(day, "68.04", FNS, "51", None, due, "уплата налога на прибыль")
if month == 12:
profit = -lg.balance(day, "99").get(("99", None), D(0))
if profit > 0:
lg.post(day, "99", None, "84", None, profit, "реформация баланса")
elif profit < 0:
lg.post(day, "84", None, "99", None, -profit, "реформация баланса")
def months(ps: list[Period]) -> list[tuple[int, int]]:
"""Месяцы книги: с января года первого периода до конца последнего периода."""
end = book_end(ps)
out = []
y, mth = ps[0].year, 1
while (y, mth) <= (end.year, end.month):
out.append((y, mth))
y, mth = (y + 1, 1) if mth == 12 else (y, mth + 1)
return out
def build_ledgers(
cfg: Config,
group: Group,
ps: list[Period],
plan: CounterpartyPlan,
extra: Callable[[dict[str, Ledger], int, int], None] | None = None,
) -> dict[str, Ledger]:
"""Книги всех членов: входящие остатки + помесячно (внешние операции, `extra`, закрытие)."""
ledgers = open_books(cfg, group, ps, plan)
for year, month in months(ps):
post_month(ledgers, cfg, group, plan, year, month)
if extra is not None:
extra(ledgers, year, month)
close_month(ledgers, year, month)
return ledgers

View File

@ -9,7 +9,7 @@ from pathlib import Path
CENT = Decimal("0.01")
@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class An:
cp: str | None
contract: str | None = None
@ -18,7 +18,7 @@ class An:
inn_override: str | None = None
@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class Posting:
day: date
dt: str
@ -37,12 +37,22 @@ class Ledger:
self.company = company
self.postings: list[Posting] = []
self._cache: dict = {}
# Индекс для быстрого сальдо «на конец»: накопленные итоги по (счёт, аналитика) и последний день проводок.
# Закрытие месяца спрашивает сальдо на дату не раньше всех проводок — тогда достаточно пройти по ключам, а не по проводкам.
self._tot: dict[Key, Decimal] = {}
self._maxday: date | None = None
self.meta: dict = {} # произвольные данные модели компании (используются economy)
def post(self, day, dt, dt_an, ct, ct_an, amount, memo) -> None:
if amount <= 0 or amount != amount.quantize(CENT):
raise ValueError(f"сумма должна быть > 0 с двумя знаками: {amount}")
self.postings.append(Posting(day, dt, dt_an, ct, ct_an, amount, memo))
self._cache.clear()
kd, kc = (dt, dt_an), (ct, ct_an)
self._tot[kd] = self._tot.get(kd, Decimal(0)) + amount
self._tot[kc] = self._tot.get(kc, Decimal(0)) - amount
if self._maxday is None or day > self._maxday:
self._maxday = day
@staticmethod
def _match(code: str, prefix: str | None) -> bool:
@ -52,13 +62,18 @@ class Ledger:
ck = ("b", at, account_prefix)
if ck not in self._cache:
res: dict[Key, Decimal] = {}
for p in self.postings:
if p.day > at:
continue
if self._match(p.dt, account_prefix):
res[(p.dt, p.dt_an)] = res.get((p.dt, p.dt_an), Decimal(0)) + p.amount
if self._match(p.ct, account_prefix):
res[(p.ct, p.ct_an)] = res.get((p.ct, p.ct_an), Decimal(0)) - p.amount
if self._maxday is None or at >= self._maxday:
for key, v in self._tot.items():
if self._match(key[0], account_prefix):
res[key] = v
else:
for p in self.postings:
if p.day > at:
continue
if self._match(p.dt, account_prefix):
res[(p.dt, p.dt_an)] = res.get((p.dt, p.dt_an), Decimal(0)) + p.amount
if self._match(p.ct, account_prefix):
res[(p.ct, p.ct_an)] = res.get((p.ct, p.ct_an), Decimal(0)) - p.amount
self._cache[ck] = {k: v for k, v in res.items() if v != 0}
return dict(self._cache[ck])

46
synthgen/periods.py Normal file
View File

@ -0,0 +1,46 @@
"""Отчётные периоды (спека §5.1): кварталы подряд, каждый — нарастающим итогом с начала года, как выгружает 1С."""
from dataclasses import dataclass
from datetime import date
_LABELS = {1: "1 квартал {y} г.", 2: "1 полугодие {y} г.", 3: "9 месяцев {y} г.", 4: "{y} г."}
_END = {1: (3, 31), 2: (6, 30), 3: (9, 30), 4: (12, 31)}
@dataclass(frozen=True)
class Period:
key: str # 2025-Q1, 2025-Q2, 2025-Q3, 2025-FY
year: int
quarter: int
start: date # всегда 1 января (нарастающий итог)
end: date
label_1c: str
def _make(year: int, quarter: int) -> Period:
month, day = _END[quarter]
key = f"{year}-FY" if quarter == 4 else f"{year}-Q{quarter}"
return Period(key, year, quarter, date(year, 1, 1), date(year, month, day), _LABELS[quarter].format(y=year))
def build_periods(start: str, n: int) -> list[Period]:
"""`n` периодов подряд начиная с `start` (`ГГГГ-Qn`); четвёртый квартал — годовой период `ГГГГ-FY`."""
year_s, q_s = start.split("-Q")
year, quarter = int(year_s), int(q_s)
out: list[Period] = []
for _ in range(n):
out.append(_make(year, quarter))
quarter += 1
if quarter == 5:
year, quarter = year + 1, 1
return out
def book_start(ps: list[Period]) -> date:
"""Дата входящих остатков: 31 декабря года перед первым периодом."""
return date(ps[0].year - 1, 12, 31)
def book_end(ps: list[Period]) -> date:
"""Конец книги — конец последнего периода."""
return ps[-1].end

View File

@ -0,0 +1,63 @@
from dataclasses import replace
import pytest
from synthgen import dictionary
from synthgen.config import Config
from synthgen.counterparties import WEIGHTS, plan
from synthgen.group import build_group
@pytest.fixture(scope="module")
def orgs():
return dictionary.load()
def _mean_len(p):
return sum(len(v) for v in p.by_file.values()) / len(p.by_file)
def test_mean_matches(orgs):
cfg = Config(group_size=10, periods=4, osv_counterparties_mean=60)
p = plan(cfg, build_group(cfg, orgs))
assert not p.capped
assert len(p.by_file) == 10 * len(WEIGHTS)
assert abs(_mean_len(p) - 60) <= 0.15 * 60
def test_cap_and_flag(orgs):
cfg = Config(group_size=40, periods=4, osv_counterparties_mean=150)
g = build_group(cfg, orgs)
p = plan(cfg, g)
assert p.capped
bank_inns = {b.inn for b in g.banks}
for (code, acct), lst in p.by_file.items():
assert len(lst) == len({o.inn for o in lst})
if acct in ("66", "67"):
assert len(lst) <= len(g.banks) and all(o.inn in bank_inns for o in lst)
else:
assert len(lst) <= len(g.externals)
def test_sets_disjoint_from_group(orgs):
cfg = Config(group_size=8, osv_counterparties_mean=100)
g = build_group(cfg, orgs)
p = plan(cfg, g)
forbidden = {m.org.inn for m in g.members} | {b.inn for b in g.banks}
for (code, acct), lst in p.by_file.items():
if acct in ("58", "60", "62", "76"):
assert not forbidden & {o.inn for o in lst}
def test_zero_mean(orgs):
cfg = Config(group_size=4, osv_counterparties_mean=0)
p = plan(cfg, build_group(cfg, orgs))
assert all(v == [] for v in p.by_file.values()) and not p.capped
def test_plan_deterministic(orgs):
cfg = Config(group_size=6)
g = build_group(cfg, orgs)
assert plan(cfg, g) == plan(cfg, g)
other = plan(replace(cfg, seed=2), build_group(replace(cfg, seed=2), orgs))
assert other != plan(cfg, g)

View File

@ -0,0 +1,120 @@
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 build_ledgers, last_day, months
from synthgen.group import build_group
from synthgen.periods import book_start, build_periods
D = Decimal
def _build(**kw):
cfg = Config(group_size=kw.pop("group_size", 3), periods=kw.pop("periods", 4), osv_counterparties_mean=kw.pop("mean", 20), **kw)
g = build_group(cfg, dictionary.load())
ps = build_periods(cfg.start, cfg.periods)
pl = make_plan(cfg, g)
return cfg, g, ps, pl, build_ledgers(cfg, g, ps, pl)
@pytest.fixture(scope="module")
def built():
return _build()
def test_books_balanced(built):
cfg, g, ps, pl, books = built
start = book_start(ps)
for lg in books.values():
for day in (start, date(2025, 3, 31), date(2025, 12, 31)):
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]
def test_bs_identity(built):
cfg, g, ps, pl, books = built
for lg in books.values():
for p in ps:
day = p.end
for acct in ("90", "91"):
assert lg.balance(day, acct) == {}
if p.quarter == 4:
assert lg.balance(day, "99") == {}
bal = lg.balance(day)
assert sum(v for (a, _), v in bal.items() if a != "000") == 0
# активы не уходят в минус: денежные средства на конец каждого месяца
for y, m in months(ps):
assert lg.balance(last_day(y, m), "51").get(("51", None), D(0)) >= 0
def test_externals_used(built):
cfg, g, ps, pl, books = built
for (code, acct), lst in pl.by_file.items():
if not lst or acct not in ("58", "60", "62", "66", "67", "76"):
continue
seen = set()
for p in books[code].postings:
for an in (p.dt_an, p.ct_an):
if an is not None and an.cp:
seen.add(an.cp)
used = sum(1 for o in lst if o.inn in seen)
assert used >= 0.8 * len(lst), (code, acct, used, len(lst))
def test_pareto_shape(built):
cfg, g, ps, pl, books = built
tot = {code: sum(c for (a, _), (_, c) in lg.turnover(date(2025, 1, 1), date(2025, 12, 31), "62").items()) for code, lg in books.items()}
biggest = max(tot, key=tot.get)
by_cp = {}
for (a, an), (dt, ct) in books[biggest].turnover(date(2025, 1, 1), date(2025, 12, 31), "62").items():
if an is not None:
by_cp[an.cp] = by_cp.get(an.cp, D(0)) + dt + ct
vals = sorted(by_cp.values(), reverse=True)
top = max(1, len(vals) // 10)
assert sum(vals[:top]) >= D("0.5") * sum(vals)
def test_signs_variety(built):
cfg, g, ps, pl, books = built
for acct in ("60", "62"):
signs = set()
for lg in books.values():
for v in lg.balance(date(2025, 12, 31), acct).values():
signs.add(v > 0)
assert signs == {True, False}, acct
def test_year_rollover():
cfg, g, ps, pl, books = _build(start="2025-Q3")
dec = date(2025, 12, 31)
for lg in books.values():
assert lg.balance(dec, "90") == {} and lg.balance(dec, "91") == {} and lg.balance(dec, "99") == {}
assert lg.balance(dec, "84") != lg.balance(date(2024, 12, 31), "84")
t = lg.turnover(date(2026, 1, 1), date(2026, 1, 1), "90")
assert t == {} # 2026 начинается с нуля; обороты 90 в январе — не 1 января
jan = lg.turnover(date(2026, 1, 1), date(2026, 1, 31), "90")
assert any(dt or ct for dt, ct in jan.values())
assert max(p.day for lg in books.values() for p in lg.postings) == date(2026, 6, 30)
def test_extra_hook_called_before_close():
cfg = Config(group_size=2, periods=1, osv_counterparties_mean=5)
g = build_group(cfg, dictionary.load())
ps = build_periods(cfg.start, 1)
seen = []
def hook(ledgers, year, month):
seen.append((year, month, len(ledgers["C01"].postings)))
build_ledgers(cfg, g, ps, make_plan(cfg, g), extra=hook)
assert [(y, m) for y, m, _ in seen] == [(2025, 1), (2025, 2), (2025, 3)]
def test_deterministic_and_zero_mean():
a = _build(mean=0)[4]
b = _build(mean=0)[4]
assert [p for p in a["C01"].postings] == [p for p in b["C01"].postings]
assert a["C01"].postings

View File

@ -0,0 +1,23 @@
from datetime import date
from synthgen.periods import book_end, book_start, build_periods
def test_build_periods_simple():
ps = build_periods("2025-Q1", 4)
assert [p.key for p in ps] == ["2025-Q1", "2025-Q2", "2025-Q3", "2025-FY"]
assert [p.label_1c for p in ps] == ["1 квартал 2025 г.", "1 полугодие 2025 г.", "9 месяцев 2025 г.", "2025 г."]
assert all(p.start == date(2025, 1, 1) for p in ps)
assert [p.end for p in ps] == [date(2025, 3, 31), date(2025, 6, 30), date(2025, 9, 30), date(2025, 12, 31)]
def test_build_periods_rollover():
ps = build_periods("2025-Q3", 4)
assert [p.key for p in ps] == ["2025-Q3", "2025-FY", "2026-Q1", "2026-Q2"]
assert ps[2].start == date(2026, 1, 1) and ps[2].year == 2026 and ps[2].quarter == 1
assert book_end(ps) == date(2026, 6, 30)
def test_book_start():
assert book_start(build_periods("2025-Q1", 4)) == date(2024, 12, 31)
assert book_start(build_periods("2025-Q3", 4)) == date(2024, 12, 31)