From 0abe99bf77e6745255ade5a722126b8323e13450 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 13 Nov 2026 01:04:15 +0400 Subject: [PATCH] =?UTF-8?q?synthgen:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=D1=83=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(me?= =?UTF-8?q?an=3D0,=20=D0=91=D0=91=20=D0=B7=D0=B0=D0=B9=D0=BC=D0=BE=D0=B2,?= =?UTF-8?q?=20--force,=20README=20=D0=BF=D0=BE=20=D1=8F=D0=B4=D1=80=D1=83)?= =?UTF-8?q?=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../specs/2026-11-12-synthgen-design.md | 3 + synthgen/README.md | 10 ++- synthgen/counterparties.py | 3 + synthgen/forms.py | 27 +++++--- synthgen/generate.py | 23 ++++++- synthgen/intragroup.py | 8 ++- synthgen/report.py | 62 +++++++++++++------ synthgen/tests/test_counterparties.py | 4 +- synthgen/tests/test_forms.py | 27 ++++++-- synthgen/tests/test_generate.py | 36 ++++++++++- synthgen/tests/test_osv_parser.py | 7 ++- 11 files changed, 166 insertions(+), 44 deletions(-) diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index 5e4eace..9601284 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -302,3 +302,6 @@ README прогона, точные субсчета внутри перечис | 38 | 12.11.2026 | ОФР: 2411 = 2410 (налог текущий), 2412 = 0, 2460/2510/2520/2530/2900/2910 = «-»; 2500 = 2400 + 2510 + 2520 + 2530; подписи колонок: «За 3 месяца / 6 месяцев / 9 месяцев\n<год> года», для года — «За <год> год» (ОФР) и «На 31 декабря\n<год> года» (ББ); дата шапки — конец периода | отложенных налогов и прочих результатов модель не создаёт; подписи по образцу 1С, парсер определяет даты колонок | OPEN-QUESTION: форма подписей «За 3 месяца» для 1 квартала у реальных выгрузок не проверена | | 39 | 12.11.2026 | В шаблонах ББ/ОФР сохранены флаги образца (`M15`/`R15` ББ, нумерация колонок ОФР); реквизиты (ОКПО, ОКВЭД по отрасли, ОКОПФ 65/47, адрес, ФИО подписантов) — детерминированные вымышленные из `stable_hash` по ИНН/компании/периоду | формы 66н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами | | 40 | 13.11.2026 | Сборка набора: пустые по правилу 1С ОСВ тоже пишутся (шапка и «Итого» с нулями), число файлов не зависит от экономики; каталог результата по умолчанию `./out/`; `--force` очищает содержимое `out` целиком; в `config.yaml` и README `out` всегда `null` (путь результата в файлы не попадает) | детерминированность побайтно при разных `--out`; ожидаемое число файлов предсказуемо (компании × периоды × 9 счетов) | `--force` стирает всё в `out` — указывать только каталог набора; пустой файл ОСВ может смутить загрузчик, ждущий непустых | +| 41 | 13.11.2026 | I-1: при `osv_counterparties_mean = 0` файлы 60, 62 и 76 получают по одному служебному внешнему контрагенту (первый из выборки словаря), остальные счета остаются без внешних | внешние обороты без аналитики и ВГО с аналитикой на одном субсчёте 60.01/62.01 запрещены свёрткой ОСВ; строка «<...>» потребовала бы отдельной проверки парсером | при mean = 0 «внешних» не ноль, а по одному в 60/62/76 (README оговаривает) | +| 42 | 13.11.2026 | I-3: в ББ головной 58.03 по аналитике: заём дочерней с кредиторским 67.03 → 1170, с 66.03 → 1240, внешние 58.03 → 1240 (раньше все 58.03 головной — 1170); `balance_rub` получил параметр `seed` | правила 3/4 ядра сопоставляют 1170↔1410 и 1240↔1510; срок займа в synthgen случайный | внешние 58.03 головной теперь в 1240 (оборотные), а не в 1170 | +| 43 | 13.11.2026 | I-4: `--force` чистит только каталог с `config.yaml` и README, начинающимся с «# Синтетический набор»; отказ при `.git` и если `out` — файл (код 3); приёмка парсером расширена на все 9 счетов; README прогона переписан по поведению ядра (MULTIPLE_RULES, правила 3/4/6/7/8) | `--force` при `--out .` стирал `.git`; прежние формулировки о правиле 6 и 2110 не подтвердились по ядру | набор с вручную удалённым README `--force` не очистит (удалять вручную) | diff --git a/synthgen/README.md b/synthgen/README.md index e143ccf..fd8c11b 100644 --- a/synthgen/README.md +++ b/synthgen/README.md @@ -59,9 +59,13 @@ uv run --with openpyxl==3.1.5 --with pyyaml==6.0.2 python -m synthgen \ - Не оракул; только форма 66н (ФСБУ 4/2023 не покрыта). - Среднее число контрагентов ограничено словарём (≤ 150): при потолке README набора пишет «среднее ограничено словарём». - ИНН компаний группы берутся из диапазона по хешу префикса; два префикса могут столкнуться (малая вероятность). -- ВГО по выручке 90.01 без аналитики; правило 6 ядра суммирует 62↔60 и 76↔76; счёт 80 головной без аналитики; - парсером проверены ОСВ 60/62/76; подпись «За 3 месяца» ОФР не сверена с реальной выгрузкой — подробности в README - каждого прогона. +- Выручка 90.01 в ОСВ без аналитики по контрагентам; счёт 80 головной без аналитики; подпись «За 3 месяца» в ОФР не + сверена с реальной выгрузкой первого квартала; внешние 58.03 и 76.05 только по Кт не проверялись на ядре. +- Побайтная воспроизводимость — в пределах одной платформы (float в весах и масштабах). +- Как ядро увидит расхождения (торговля и заём выше 5 % — нераспределённые строки `MULTIPLE_RULES`, до 5 % — автоматически), + какие особенности у данных и как прочитать пары — в README каждого прогона. +- `--force` чистит только каталог, который узнаёт как набор synthgen (`config.yaml` и README с заголовком набора), и + отказывает при `.git` в каталоге. ## Как пересобрать словарь и шаблоны diff --git a/synthgen/counterparties.py b/synthgen/counterparties.py index 9dd0dbc..bdcd562 100644 --- a/synthgen/counterparties.py +++ b/synthgen/counterparties.py @@ -18,6 +18,7 @@ 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") +SERVICE_ACCOUNTS = ("60", "62", "76") # при mean = 0 в этих файлах один служебный внешний контрагент @dataclass(frozen=True) @@ -56,6 +57,8 @@ def plan(cfg: Config, group: Group) -> CounterpartyPlan: 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] + if cfg.osv_counterparties_mean == 0 and acct in SERVICE_ACCOUNTS: + want = [1] * len(codes) # решение 41: при mean = 0 расчёты идут через одного служебного внешнего контрагента got = [min(n, cap) for n in want] shortfall = sum(want) - sum(got) if shortfall > 0: diff --git a/synthgen/forms.py b/synthgen/forms.py index 984a828..75864aa 100644 --- a/synthgen/forms.py +++ b/synthgen/forms.py @@ -11,16 +11,19 @@ Закрывающие проводки месяца (`KIND_CLOSE_90`, `KIND_CLOSE_99`) в ОФР не входят, закрытие затрат (`KIND_CLOSE_COST`) входит. """ +import re from dataclasses import dataclass, field from datetime import date from decimal import ROUND_HALF_UP, Decimal from synthgen.chart import is_off_balance from synthgen.group import Member -from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, Ledger +from synthgen.intragroup import loan_account_for_seed +from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, An, Ledger from synthgen.periods import Period, book_end, book_start from synthgen.rng import rng_for +_MEMBER_CODE = re.compile(r"C\d{2,}") # аналитика контрагента-компании группы (внешние — ИНН) D = Decimal ZERO = D(0) ONE = D(1) @@ -67,7 +70,7 @@ class Form: # ---------------------------------------------------------------- рубли -def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]: +def _classify(acct: str, an: An | None, bal: Decimal, member: Member, seed: int) -> tuple[str, Decimal]: """(код строки, сумма со знаком строки) для одного сальдо (Дт > 0).""" root = acct.split(".")[0] if root in ("01", "02", "03"): @@ -85,7 +88,11 @@ def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]: if acct == "58.01": return "1170", bal if acct == "58.03": - return ("1170" if member.is_head else "1240"), bal # займы головной — на срок > 12 мес. + # решение 42: заём дочерней (аналитика — её код) по сроку её кредиторского субсчёта: 67.03 → 1170, 66.03 → 1240 + # (правила 3/4 ядра); внешние 58.03 — 1240 + if an is not None and an.cp is not None and _MEMBER_CODE.fullmatch(an.cp) and loan_account_for_seed(seed, an.cp) == "67.03": + return "1170", bal + return "1240", bal if root in RECEIVABLE_ROOTS: if acct == "76.07.1" and bal < 0: return "1450", -bal # арендные обязательства считаем долгосрочными @@ -101,12 +108,12 @@ def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]: raise ValueError(f"{member.code}: нет правила строки ББ для {acct} (сальдо {bal})") -def _balance_leaves(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]: +def _balance_leaves(ledger: Ledger, at: date, member: Member, seed: int) -> dict[str, Decimal]: leaves = {c: ZERO for c in ASSET_LEAVES + LIABILITY_LEAVES} - for (acct, _), bal in sorted(ledger.balance(at).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): + for (acct, an), bal in sorted(ledger.balance(at).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): if acct == "000" or is_off_balance(acct): continue - code, amount = _classify(acct, bal, member) + code, amount = _classify(acct, an, bal, member, seed) leaves[code] += amount return leaves @@ -120,9 +127,9 @@ def _add_totals(values: dict[str, Decimal]) -> dict[str, Decimal]: return out -def balance_rub(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]: +def balance_rub(ledger: Ledger, at: date, member: Member, seed: int) -> dict[str, Decimal]: """Код строки -> рубли (спека §5.6 п. 3): сальдо по строке, 000 и забалансовые не входят.""" - full = _add_totals(_balance_leaves(ledger, at, member)) + full = _add_totals(_balance_leaves(ledger, at, member, seed)) if full["1600"] != full["1700"]: raise ValueError(f"{ledger.company} на {at}: актив {full['1600']} != пассив {full['1700']}") return {c: full[c] for c in BALANCE_CODES} @@ -188,6 +195,8 @@ def _plug(rub: dict[str, Decimal]) -> tuple[dict[str, Decimal], tuple[str, ...]] if rub[c] == 0: continue delta = step if c in ASSET_LEAVES else -step + if rub[c] > 0 and out[c] + delta < 0: + continue # подгонка не должна превращать положительную строку в отрицательную cands.append((abs(out[c] + delta - rub[c] / THOUSAND), c, delta)) cands.sort() need = int(abs(diff)) @@ -238,7 +247,7 @@ def balance(ledger: Ledger, member: Member, period: Period, ps: list[Period], se base_rub: dict[str, Decimal] | None = None for i, day in enumerate(dates): if lo <= day <= hi: - rub = balance_rub(ledger, day, member) + rub = balance_rub(ledger, day, member, seed) base_rub = base_rub or rub values, plug = _plug(rub) from_book.append(True) diff --git a/synthgen/generate.py b/synthgen/generate.py index 251b647..86c7aab 100644 --- a/synthgen/generate.py +++ b/synthgen/generate.py @@ -24,10 +24,31 @@ def default_out(cfg: Config) -> Path: return cfg.out if cfg.out is not None else Path("out") / cfg.prefix +SET_MARKER = "# Синтетический набор" + + +class NotASetError(FileExistsError): + """`--force` отказывается чистить каталог, не похожий на набор synthgen.""" + + +def _looks_like_set(out: Path) -> bool: + readme, config = out / "README.md", out / "config.yaml" + if not (readme.is_file() and config.is_file()): + return False + with readme.open(encoding="utf-8", errors="replace") as fh: + return fh.readline().startswith(SET_MARKER) + + def _prepare(out: Path, force: bool) -> None: + if out.exists() and not out.is_dir(): + raise FileExistsError(f"каталог результата не каталог, а файл: {out}") if out.exists() and any(out.iterdir()): if not force: - raise FileExistsError(f"каталог результата не пуст: {out} (--force перезапишет)") + raise FileExistsError(f"каталог результата не пуст: {out} (--force перезапишет прежний набор)") + if (out / ".git").exists(): + raise NotASetError(f"{out} содержит .git: чистить такой каталог нельзя") + if not _looks_like_set(out): + raise NotASetError(f"{out} не похож на набор synthgen (нет config.yaml и README.md с заголовком набора): не чищу") for item in out.iterdir(): shutil.rmtree(item) if item.is_dir() and not item.is_symlink() else item.unlink() out.mkdir(parents=True, exist_ok=True) diff --git a/synthgen/intragroup.py b/synthgen/intragroup.py index 2489e7e..1f9f8f4 100644 --- a/synthgen/intragroup.py +++ b/synthgen/intragroup.py @@ -61,9 +61,13 @@ class VgoPair: mismatches: tuple[Mismatch, ...] = () # по одному на год, где пара выбрана (решение 27) -def loan_account(cfg: Config, child_code: str) -> str: +def loan_account_for_seed(seed: int, 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" + return "66.03" if rng_for(seed, "loan-term", child_code).random() < 0.5 else "67.03" + + +def loan_account(cfg: Config, child_code: str) -> str: + return loan_account_for_seed(cfg.seed, child_code) def plan_pairs(cfg: Config, group: Group) -> list[VgoPair]: diff --git a/synthgen/report.py b/synthgen/report.py index 3b91123..116e8a1 100644 --- a/synthgen/report.py +++ b/synthgen/report.py @@ -47,8 +47,8 @@ def _no_inn(group: Group) -> list[str]: other = by_code[group.variant_name[code]] lines.append( f"- {code}: ОСВ без колонки ИНН; контрагент {other.code} ({other.name}) записан в ней вариантным " - f"написанием «{variant_spelling(other)}» (корень, префикс и форма без кавычек). Сопоставит ли ядро " - "такое имя с компанией группы автоматически, не проверено (OPEN-QUESTION O-2)." + f"написанием «{variant_spelling(other)}» (корень, префикс и форма без кавычек). Ядро " + "такое имя автоматически не сопоставит (имя нормализуется с сохранением порядка слов): нужна ручная привязка алиаса." ) return [*lines, ""] @@ -63,12 +63,20 @@ def _pairs(group: Group, pairs: list[VgoPair]) -> list[str]: return [*lines, "", "Расхождений сторон в этом прогоне нет.", ""] lines += [ "", "Расхождения: сумма — разница сторон, % — от меньшей стороны сальдо пары на конец периода; порог 5 %.", "", - "| Пара (продавец/займодавец → покупатель/заёмщик) | Вид | Период | Проводка у | Сумма, руб. | % | Порог |", - "|---|---|---|---|---|---|---|", + "| Пара (продавец/займодавец → покупатель/заёмщик) | Вид | Период | Проводка у | Сумма, руб. | % | Порог | Ожидание в ядре |", + "|---|---|---|---|---|---|---|---|", ] # fmt: skip for p, m in mism: verdict = "выше порога 5 %" if m.above_threshold else "ниже порога 5 %" - lines.append(f"| {p.a} → {p.b} | {KIND[p.kind]} | {m.period_key} | {m.side} | {_money(m.amount)} | {m.pct} % | {verdict} |") + if not m.above_threshold: + expect = "закрывается автоматически (до 5 %)" + elif p.kind == "trade": + expect = "62.01 продавца нераспределена (MULTIPLE_RULES), 60.01 покупателя — правило 6 односторонне" + else: + expect = "58.03 и 66.03/67.03 нераспределены (MULTIPLE_RULES)" + lines.append( + f"| {p.a} → {p.b} | {KIND[p.kind]} | {m.period_key} | {m.side} | {_money(m.amount)} | {m.pct} % | {verdict} | {expect} |" + ) return [*lines, ""] @@ -89,19 +97,37 @@ def _counts(plan: CounterpartyPlan, group: Group) -> list[str]: NOTES = """\ ## Что нужно знать при чтении результатов -- Набор не оракул: ожидаемых итогов консолидации здесь нет, README — журнал того, что заложил генератор. Только форма 66н. -- ВГО по выручке (90.01) в ОСВ идут без аналитики по контрагентам (M-4, T4.6): взаимные продажи внутри группы - видны только по расчётным счетам, и при элиминации выручка может попасть в строку 2110 дважды. -- Правило 6 ядра суммирует обороты пар 62 ↔ 60 и 76 ↔ 76 (M-5, T4.7): расхождение одной пары по расчётам и по - займам/прочим расчётам складывается, а не сравнивается раздельно. -- Счёт 80 у головной компании без аналитики (T4.10): строка 80 не раскрывается по участникам. -- Парсером tabular-parser проверены только ОСВ по счетам 60, 62 и 76 (O-5); остальные счета (01, 58, 66, 67, 80, 90) - покрыты юнит-тестами формы. -- Подпись колонки ОФР «За 3 месяца» (и «6 месяцев», «9 месяцев») сделана по образцу 1С, с реальной выгрузкой не - сверена (O-6); строки 2340 и 2350 в ОФР всегда нулевые. -- Счёт 58 с внешними контрагентами (займы 58.03) и 76.05 только по Кт — OPEN-QUESTION O-3: нужна проверка, как ядро - трактует такие внешние стороны. -- Вариантное имя контрагента в ОСВ без ИНН: см. раздел выше (O-2). +Набор не оракул: ожидаемых итогов консолидации здесь нет, README — журнал того, что заложил генератор. Только форма 66н. +Одинаковые настройки и seed дают побайтно одинаковые файлы в пределах одной платформы (веса и масштабы считаются во float, +на другой платформе округление может отличаться). + +### Как ядро увидит расхождения на «Таблице корректировок» + +Принадлежность строки правилу ядро определяет только по счёту и стороне, не зная, есть ли у пары встречные счета: + +- расхождение до 5 % и пары без расхождений закрываются точными или «до 5 %» упаковками — автоматически; +- торговля с расхождением выше 5 %: строка 62.01 продавца (по дебету) входит в правила 6, 7 и 8, поэтому она + нераспределённая (`MULTIPLE_RULES`), а 60.01 покупателя (только правило 6) уходит в правило 6 односторонне (`AUTO`); + на экране «Пары ВГО» (API `elimination-totals`) такая пара подсвечивается; +- заём с расхождением выше 5 %: 58.03 займодавца входит в правила 3 и 4, 66.03 заёмщика — в правила 4 и 8, 67.03 — в 3 и 7; + обе строки нераспределённые (`MULTIPLE_RULES`); +- правило 6 считает сальдо на конец периода (62.01 продавца ↔ 60.01 покупателя); 76 между компаниями группы на конец + периода равно нулю (проценты и дивиденды гасятся в месяце начисления); +- правил элиминации для ОФР в ядре нет; выручка по 90.01 в ОСВ идёт без аналитики по контрагентам, как обычно в 1С. + +### Особенности данных + +- Счёт 80 у головной компании без аналитики: строка 80 не раскрывается по участникам (парсер даёт информационный + `OSV_HIERARCHY_BY_TRIVIAL`). +- В ББ головной займы дочерним: 67.03 попадают в строку 1170, 66.03 — в 1240, внешние 58.03 — в 1240 (правила 3 и 4). +- Вариантное имя контрагента в ОСВ без ИНН (раздел выше) ядро автоматически не сопоставит: нужна ручная привязка алиаса. +- ОСВ всех девяти счетов набора проходят парсер tabular-parser без ошибок. +- Подпись колонки ОФР «За 3 месяца» (и «6 месяцев», «9 месяцев») сделана по образцу 1С; для первого квартала с реальной + выгрузкой не сверена; строки 2340 и 2350 в ОФР всегда нулевые. +- Внешние контрагенты по счёту 58 (займы 58.03) и 76.05 только по Кт — вопрос к ядру: как оно трактует такие внешние + стороны; не проверялось. +- При `osv_counterparties_mean = 0` в ОСВ 60, 62 и 76 остаётся по одному служебному внешнему контрагенту, чтобы строки + расчётов с компаниями группы и внешние обороты не смешивались на одном субсчёте. """ diff --git a/synthgen/tests/test_counterparties.py b/synthgen/tests/test_counterparties.py index 8738b22..b59a13d 100644 --- a/synthgen/tests/test_counterparties.py +++ b/synthgen/tests/test_counterparties.py @@ -52,7 +52,9 @@ def test_sets_disjoint_from_group(orgs): 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 + assert not p.capped + for (code, acct), v in p.by_file.items(): + assert len(v) == (1 if acct in ("60", "62", "76") else 0), (code, acct) # решение 41 def test_plan_deterministic(orgs): diff --git a/synthgen/tests/test_forms.py b/synthgen/tests/test_forms.py index 0457011..9dfe2e8 100644 --- a/synthgen/tests/test_forms.py +++ b/synthgen/tests/test_forms.py @@ -61,9 +61,8 @@ def test_balance_identity(built): def test_balance_matches_ledger(built, forms_world): _, _, _, books = forms_world - adjusted = 0 for m, p, bal, _ in built: - rub = forms.balance_rub(books[m.code], p.end, m) + rub = forms.balance_rub(books[m.code], p.end, m, forms_world[0].seed) for code in forms.BALANCE_CODES: if code in SECTIONS or code in ("1600", "1700"): continue @@ -71,11 +70,9 @@ def test_balance_matches_ledger(built, forms_world): got = bal.values[code][0] if code in bal.plugged[0]: assert abs(got - want) == 1, (m.code, p.key, code) - adjusted += 1 else: assert got == want, (m.code, p.key, code, got, want) assert bal.values["1600"][0] == sum((bal.values[c][0] for c in SECTIONS["1100"] + SECTIONS["1200"]), ZERO) - assert adjusted >= 0 def test_plug_is_minimal_and_recorded(built): @@ -91,7 +88,7 @@ def test_first_prior_column_from_book(built, forms_world): for m, p, bal, _ in built: if p.year != first.year: continue - rub = forms.balance_rub(books[m.code], day, m) + rub = forms.balance_rub(books[m.code], day, m, forms_world[0].seed) for code in forms.BALANCE_CODES: if code in SECTIONS or code in ("1600", "1700"): continue @@ -110,7 +107,7 @@ def test_columns_inside_book_come_from_book(built, forms_world): continue assert bal.from_book == [True, True, True] for col, day in enumerate((p.end, date(2025, 12, 31), date(2024, 12, 31))): - rub = forms.balance_rub(books[m.code], day, m) + rub = forms.balance_rub(books[m.code], day, m, forms_world[0].seed) for code in ("1150", "1230", "1250", "1520", "1370"): assert abs(bal.values[code][col] - _thousands(rub[code])) <= 1, (m.code, col, code) @@ -235,3 +232,21 @@ def test_column_labels(built): assert bal.columns[0] == "На 31 декабря\n2025 года" and inc.columns == ["За 2025 год", "За 2024 год"] assert by["2025-Q1"][1].columns[0] == "За 3 месяца\n2025 года" assert by["2025-Q2"][1].columns[1] == "За 6 месяцев\n2024 года" + + +def test_head_loans_by_term(forms_world): + """Решение 42: займы головной классифицируются по сроку кредиторского субсчёта дочерней (правила 3/4 ядра).""" + from synthgen.intragroup import loan_account + + cfg, group, periods, books = forms_world + head = group.members[0] + at = periods[0].end + rub = forms.balance_rub(books[head.code], at, head, cfg.seed) + bal = books[head.code].balance(at) + terms = {c.code: loan_account(cfg, c.code) for c in group.members[1:]} + assert {"67.03", "66.03"} <= set(terms.values()) or len(terms) < 3 + long_ = sum((v for (a, an), v in bal.items() if a == "58.03" and an and terms.get(an.cp) == "67.03"), ZERO) + other = sum((v for (a, an), v in bal.items() if a == "58.03" and not (an and terms.get(an.cp) == "67.03")), ZERO) + assert long_ > 0 + assert rub["1170"] == long_ + sum((v for (a, _), v in bal.items() if a == "58.01"), ZERO) + assert rub["1240"] >= other diff --git a/synthgen/tests/test_generate.py b/synthgen/tests/test_generate.py index 374e3d1..92bd826 100644 --- a/synthgen/tests/test_generate.py +++ b/synthgen/tests/test_generate.py @@ -73,6 +73,29 @@ def test_out_not_empty(tmp_path, capsys): assert str(out) in capsys.readouterr().out +def test_force_refuses_foreign_dir(tmp_path, capsys): + foreign = tmp_path / "foreign" + foreign.mkdir() + (foreign / "data.txt").write_text("чужое", encoding="utf-8") + with pytest.raises(FileExistsError): + gen.generate(SMALL, foreign, force=True) + assert (foreign / "data.txt").exists() + args = ["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--force"] + assert main([*args, "--out", str(foreign)]) == 3 + assert "не похож на набор" in capsys.readouterr().err + + repo = tmp_path / "repo" + gen.generate(SMALL, repo) + (repo / ".git").mkdir() + with pytest.raises(FileExistsError): + gen.generate(SMALL, repo, force=True) + assert (repo / ".git").exists() + + not_dir = tmp_path / "file.txt" + not_dir.write_text("x", encoding="utf-8") + assert main([*args, "--out", str(not_dir)]) == 3 + + def test_default_out_is_prefix_dir(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--set", "prefix=ZZ"]) == 0 @@ -83,12 +106,13 @@ def test_default_out_is_prefix_dir(tmp_path, monkeypatch): "overrides", [ {"group_size": 2}, + {"osv_counterparties_mean": 0}, {"group_size": 40, "periods": 1, "osv_counterparties_mean": 10}, {"no_inn_companies": 5}, {"vgo_mismatch_share": 1}, {"start": "2025-Q4", "periods": 2}, ], - ids=["size2", "size40", "all-no-inn", "all-mismatch", "from-q4"], + ids=["size2", "mean0", "size40", "all-no-inn", "all-mismatch", "from-q4"], ) def test_edge_configs(tmp_path, overrides): base = {"osv_counterparties_mean": 4, "periods": 1, **overrides} @@ -96,6 +120,12 @@ def test_edge_configs(tmp_path, overrides): manifest = gen.generate(cfg, tmp_path / "x") readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8") assert readme.strip() + if cfg.group_size <= 5: # каждый файл набора открывается + import openpyxl + + for path in manifest.files: + if path.suffix == ".xlsx": + openpyxl.load_workbook(path).close() if not manifest.pairs: assert "Пар ВГО нет" in readme @@ -121,8 +151,10 @@ def test_readme_lists_mismatches(tmp_path): def test_readme_notes(default_run): out, _ = default_run readme = (out / "README.md").read_text(encoding="utf-8") - for needle in ("90.01", "62", "76", "80", "58", "За 3 месяца", "вариантн"): + for needle in ("90.01", "MULTIPLE_RULES", "правил", "80", "58", "За 3 месяца", "вариантн", "ручн", "платформ"): assert needle in readme + for marker in ("M-4", "M-5", "T4.", "O-2", "O-3", "O-5", "O-6", "OPEN-QUESTION"): + assert marker not in readme assert "не оракул" in readme diff --git a/synthgen/tests/test_osv_parser.py b/synthgen/tests/test_osv_parser.py index 0487968..39bb38f 100644 --- a/synthgen/tests/test_osv_parser.py +++ b/synthgen/tests/test_osv_parser.py @@ -12,12 +12,13 @@ from pathlib import Path import pytest from synthgen import osv +from synthgen.counterparties import OSV_ACCOUNTS pytestmark = pytest.mark.parser TP_WT = Path(os.environ.get("TP_WT") or "/Users/andrey.epifancev/projects/awp-ca/.wt/synthetic-group/tabular-parser") PY = TP_WT / "api" / ".venv" / "bin" / "python" -ACCOUNTS = ("60", "62", "76") +ACCOUNTS = OSV_ACCOUNTS # все счета набора (решение 43: приёмка расширена с 60/62/76) def _run(path: Path) -> dict: @@ -62,7 +63,7 @@ def test_parser_inn(parsed, osv_world): bad = [] for member, account, period, data, with_inn, res in parsed: got = [r for r in res["documents"][0]["rows"] if r["group_type"] == "COUNTERPARTY" and r["data_type"] == "BU"] - assert got, (member.code, account, period.key) + assert bool(got) == any(r.is_cp for r in data), (member.code, account, period.key) # 01/80/90 без контрагентов if with_inn: for r in got: if r["counterparty_name"] in group_names and r["counterparty_inn"] != group_names[r["counterparty_name"]]: @@ -82,6 +83,8 @@ def test_parser_totals(parsed): checks = doc["checks"] if not checks["total_row_matches_root"] or checks["parent_sum_violations"] or checks["balance_identity_violations"]: bad.append((member.code, account, period.key, checks)) + if not data: # пустая ОСВ: только «Итого» + continue root = next(r for r in doc["rows"] if r["group_type"] == "ACCOUNT" and r["data_type"] == "BU") got = tuple(Decimal(str(root[k] or 0)).quantize(cent) for k in keys) if got != tuple(v.quantize(cent) for v in osv.total_of(data)):