diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index c2cb4ae..9b61e11 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -282,3 +282,8 @@ README прогона, точные субсчета внутри перечис | 18 | 12.11.2026 | Вариантное написание члена группы в ОСВ без ИНН: перестановка `<корень> <форма>` без кавычек (`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; имя группы в колонке «Группа компаний» — `ГК ` (в #35 было без «ГК») | импорт ядра принимает любые значения (обязательны ИНН, iD ГК, Группа компаний, Организация, Тип); `ГК ` задано §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 головной | diff --git a/synthgen/chart.py b/synthgen/chart.py index 103c5a4..31679a9 100644 --- a/synthgen/chart.py +++ b/synthgen/chart.py @@ -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": "Расчеты по налогам и сборам", diff --git a/synthgen/counterparties.py b/synthgen/counterparties.py new file mode 100644 index 0000000..9dd0dbc --- /dev/null +++ b/synthgen/counterparties.py @@ -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) diff --git a/synthgen/economy.py b/synthgen/economy.py new file mode 100644 index 0000000..8299336 --- /dev/null +++ b/synthgen/economy.py @@ -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 diff --git a/synthgen/ledger.py b/synthgen/ledger.py index 7c17f25..eeb4859 100644 --- a/synthgen/ledger.py +++ b/synthgen/ledger.py @@ -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]) diff --git a/synthgen/periods.py b/synthgen/periods.py new file mode 100644 index 0000000..fca4bf6 --- /dev/null +++ b/synthgen/periods.py @@ -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 diff --git a/synthgen/tests/test_counterparties.py b/synthgen/tests/test_counterparties.py new file mode 100644 index 0000000..8738b22 --- /dev/null +++ b/synthgen/tests/test_counterparties.py @@ -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) diff --git a/synthgen/tests/test_economy.py b/synthgen/tests/test_economy.py new file mode 100644 index 0000000..2371e31 --- /dev/null +++ b/synthgen/tests/test_economy.py @@ -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 diff --git a/synthgen/tests/test_periods.py b/synthgen/tests/test_periods.py new file mode 100644 index 0000000..afc04f3 --- /dev/null +++ b/synthgen/tests/test_periods.py @@ -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)