arm-ka-fixtures/docs/superpowers/plans/2026-11-12-synthgen.md
Andrey Epifancev 74eb397a93 План реализации настраиваемого генератора синтетики (#38)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:25:44 +04:00

39 KiB
Raw Blame History

Настраиваемый генератор синтетики ГСК — план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: пакет synthgen/ в репозитории fixtures, который по настройкам генерирует согласованный набор одной ГК — ОСВ, ББ, ОФР в виде выгрузок 1С и файл состава ГК для импорта ЕЦП.

Architecture: у каждой компании группы — книга проводок (Ledger, двойная запись). Экономика и операции внутри группы пишут проводки в книги; ОСВ, ББ и ОФР — чистые свёртки книг на даты периодов; запись файлов — по шаблонам, собранным из реальных выгрузок 1С. Вся случайность — из rng_for(seed, *ключи), поэтому набор детерминирован.

Tech Stack: Python 3.11, openpyxl 3.1.5, PyYAML 6.0.2, pytest 8.3.3; запуск через uv run.

Spec: docs/superpowers/specs/2026-11-12-synthgen-design.md (в этом worktree). Исполнитель читает спеку целиком; номера § ниже — её разделы, «решение N» — её таблица решений.

Global Constraints

  • Рабочий каталог: /Users/andrey.epifancev/projects/awp-ca/.wt/synthgen/fixtures (ветка feat/38-synthgen). Первой командой — cd туда. В основные checkout'ы (awp-ca/fixtures, awp-ca/tabular-parser, …) не делать cd: хук guard-main блокирует запись, если cwd — репозиторий на main; читать их файлы абсолютными путями.
  • Python: UVRUN="uv run -q --with openpyxl==3.1.5 --with pyyaml==6.0.2 --with pytest==8.3.3", запуск из корня worktree: $UVRUN python -m synthgen …, $UVRUN python -m pytest synthgen/tests -q. timeout на macOS нет.
  • Код — только в synthgen/; плюс .gitignore (строка out/). Сгенерированные наборы не коммитить.
  • Деньги — Decimal, ROUND_HALF_UP; float только при записи числа в ячейку xlsx.
  • Случайность — только через synthgen.rng.rng_for(seed, *keys); random.random()/модульный random — запрещены. Никаких datetime.now(), set-итераций без сортировки, hash() строк (рандомизирован в Python).
  • ИНН — регион 00, верная контрольная сумма; словарь — номера 0100001–0109999; компании группы — 1000000 + 100 × h(prefix) + i (§4.2, решение 10).
  • Источник копируемых модулей #35: /Users/andrey.epifancev/projects/awp-ca/.wt/synthetic-group/fixtures/synthetic-group/gen/ (inn.py, xlsx.py, ledger.py, chart.py, forms.py, ecp.py, economy.py). Копия — с docstring «Источник: synthetic-group/gen/<файл> (#35)», без импортов из synthetic-group/.
  • Образцы 1С (только чтение): ОСВ — /Users/andrey.epifancev/projects/awp-ca/fixtures/osv/rb/2024-Q1/сч 60_ 1 кв 2024 г.xlsx; ББ — /Users/andrey.epifancev/projects/awp-ca/fixtures/statements-xlsx/mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx; ОФР — …/1_Форма2_бух_09.xlsx.
  • Комментарии, docstring, README, сообщения коммитов — по-русски; идентификаторы — английские.
  • Трейлер коммита: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (или фактическая модель исполнителя).
  • Не делать push/PR. Не коммитить .superpowers/. Хук/классификатор заблокировал — не обходить, остановиться (BLOCKED).
  • Решение по реализации без владельца — строкой в таблицу «Decisions made without discussion» спеки (следующий номер 13, дата, решение, обоснование, цена ошибки) в том же коммите.

Review Focus

  1. group_size у границы (2 и 40) и no_inn_companies = group_size — генерация не падает, у каждой компании хотя бы одна пара ВГО или явная запись в README, что пар нет. → тест в Task 7 (test_edge_configs).
  2. start=…-Q4 и periods, переходящие через год (2025-Q3, 4 периода) — реформация 99→84 и входящие остатки следующего года, ББ на 31.12 одного года = «на 31 декабря» следующего года. → тест в Task 3 (test_year_rollover).
  3. osv_counterparties_mean=0 — ОСВ 60/62/76 содержат только компании группы (или пустые, но валидные с «Итого»), парсер их принимает. → тест в Task 5 (test_osv_without_externals).
  4. Префикс с кириллицей или цифрой в начале (ДЕМО, 2GK) — по §3 допустимы буквы/цифры; имя файла ЕЦП и папки строятся без ошибок. → тест в Task 2 (test_prefix_cyrillic_and_digits).
  5. Повторный прогон в существующий непустой out — старые файлы не смешиваются с новыми. Ожидание: ошибка «каталог не пуст» без --force, с --force каталог очищается. → тест в Task 7 (test_out_not_empty).

Task 1: Каркас, настройки, копии модулей #35

Files:

  • Create: synthgen/__init__.py, synthgen/__main__.py, synthgen/config.py, synthgen/rng.py, synthgen/examples/default.yaml, synthgen/inn.py, synthgen/xlsx.py, synthgen/ledger.py, synthgen/chart.py
  • Modify: .gitignore (+ out/)
  • Test: synthgen/tests/conftest.py, synthgen/tests/test_config.py, synthgen/tests/test_inn.py, synthgen/tests/test_ledger.py

Interfaces:

  • Produces:

    • config.Config — frozen dataclass: group_size: int = 5, prefix: str = "SYN", periods: int = 4, start: str = "2025-Q1", osv_counterparties_mean: int = 60, no_inn_companies: int = 1, vgo_mismatch_share: Decimal = Decimal("0.1"), seed: int = 1, out: Path | None = None.
    • config.ConfigError(ValueError); config.load_config(path: Path | None, overrides: list[str]) -> Config (overrides — строки ключ=значение; YAML и overrides приводятся к типам полей; проверки — диапазоны §3).
    • config.dump_config(cfg: Config) -> str — YAML с ключами в порядке полей.
    • rng.rng_for(seed: int, *keys: object) -> random.Random — зерно = первые 8 байт SHA-256 от repr((seed, *keys)); rng.stable_hash(text: str, modulo: int) -> int — SHA-256 → int % modulo.
    • Копии #35: inn.make_inn10(seq), inn.make_inn12(seq), inn.is_valid(inn); xlsx.save_deterministic(wb, path); ledger.An, ledger.Posting, ledger.Ledger (post, balance, turnover), ledger.opening(...); chart.ACCOUNTS, chart.parent(code), chart.is_off_balance(code) — chart.ACCOUNTS дополнить 80.09, 84, 91.09, 99, 90.09 (если нет), сверив коды и названия с /Users/andrey.epifancev/projects/awp-ca/arm-core/api/src/seed/chart_of_accounts.csv (путь найти find, если иной).
    • __main__.main(argv: list[str] | None = None) -> int — пока: разбор --config, --set (повторяемый), --out, --force; печатает dump_config и возвращает 0. Ошибка конфигурации → сообщение в stderr, код 2.
  • Step 1: Тесты. test_defaults (load_config(None, []) == Config()); test_yaml_and_set_override (YAML group_size: 7, override group_size=9 → 9; vgo_mismatch_share=0.25 → Decimal("0.25")); test_validation — параметризованный: group_size=1, group_size=41, periods=0, periods=13, start=2025-Q5, start=2019-Q4, prefix=A, prefix="A B", prefix=ABCDEFGHIJKLM, osv_counterparties_mean=151, no_inn_companies=6 при group_size=5, vgo_mismatch_share=1.5, unknown=1 → ConfigError; test_cli_bad_config_exit_2; test_rng_for_stable (rng_for(1,"a").random() дважды одинаково, rng_for(1,"a") ≠ rng_for(1,"b")); копии тестов #35 для inn и ledger (tests/test_inn.py, tests/test_ledger.py из источника, импорты gen. → synthgen.). conftest.py регистрирует маркеры parser (приёмка парсером, без окружения — skip) и slow (по умолчанию пропускается, запуск -m slow).

  • Step 2: прогнать — FAIL (нет модулей).

  • Step 3: реализовать по Interfaces; examples/default.yaml — все ключи §3 со значениями по умолчанию и русскими комментариями.

  • Step 4: $UVRUN python -m pytest synthgen/tests -q — PASS.

  • Step 5: Коммит synthgen: каркас, настройки, копии модулей #35 (#38).

Task 2: Словарь, состав группы, выгрузка ЕЦП

Files:

  • Create: synthgen/dictionary/build.py, synthgen/dictionary/companies.csv, synthgen/dictionary.py, synthgen/group.py, synthgen/ecp.py
  • Test: synthgen/tests/test_dictionary.py, synthgen/tests/test_group.py, synthgen/tests/test_ecp.py

Interfaces:

  • Consumes: inn.*, rng.*, config.Config, xlsx.save_deterministic.

  • Produces:

    • dictionary.Org — frozen: name: str (как в 1С: Северная Нива ООО, Аверина Е.И. ИП), form: str (ООО|АО|ПАО|ИП), inn: str, kpp: str (пусто у ИП), kind: str; dictionary.load() -> list[Org] (из CSV, порядок файла).
    • group.Member — frozen: code: str (C01…), org: Org, name: str (АО "<prefix> <корень>"), name_1c: str (<prefix> <корень> АО — как 1С пишет контрагента), inn: str, kpp: str, is_head: bool, head_share: Decimal | None (доля головной, у головной None), minority: Org | None.
    • group.Group — frozen: prefix, name (ГК <prefix>), gk_id (<prefix>-000001), members: list[Member], no_inn: frozenset[str] (коды), variant_name: dict[str, str] (код ОСВ-компании без ИНН → код компании группы, записанной другим написанием), externals: list[Org] (без банков и без выбранных в группу), banks: list[Org].
    • group.build_group(cfg: Config, orgs: list[Org]) -> Group; group.member_inn(prefix: str, i: int) -> str.
    • ecp.write_ecp(group: Group, path: Path) -> None.
  • Step 1: build.py — детерминированный (без rng: перебор комбинаций из списков в файле) генератор 400 записей: 30 банков (kind=банк, АО/ПАО, «… Банк»), ~40 ИП (ФИО + «ИП»), остальные ООО/АО по 6 отраслям §4.1. Номера ИНН — подряд от 0100001. python -m synthgen.dictionary.build перезаписывает companies.csv (;, UTF-8, заголовок name;form;inn;kpp;kind). CSV закоммитить.

  • Step 2: Тесты словаря: test_dictionary_size_and_kinds (400 записей, 30 банк, 30–50 ИП, имена уникальны без учёта регистра); test_dictionary_inn (все is_valid, длина 10 у ЮЛ / 12 у ИП, номер в 0100001–0109999, КПП 9 цифр у ЮЛ); test_dictionary_not_real — ни одно имя (без формы, без регистра) не встречается в названиях организаций /Users/andrey.epifancev/projects/awp-ca/fixtures/directories/ecp-export-russkiy-biskvit.xlsx и в колонке A первых листов файлов fixtures/osv/rb/2024-Q1/*.xlsx; test_build_is_reproducible (повторный build в tmp_path побайтно равен закоммиченному CSV).

  • Step 3: Тесты группы: test_group_size_and_head (group_size=5 → 5 членов, C01 — головная АО, остальные ООО/АО, никто не банк и не ИП); test_member_inn_range (member_inn("SYN", 3) валиден, номер = 1000000 + 100 × stable_hash("SYN", 90000) + 3); test_prefixes_do_not_collide (ИНН групп SYN и DEMO2 по 5 членов не пересекаются; ни один ИНН группы не в словаре); test_externals_exclude_group_and_banks; test_no_inn_selection (no_inn_companies=2 → 2 кода, головная не выбирается, пока есть дочерние; при no_inn_companies=group_size — все); test_variant_name — для каждого кода из no_inn есть ровно один другой член группы с вариантным написанием, вариант ≠ name_1c и ≠ name; test_shares (head_share ∈ [0.51, 1.00], у 1–2 дочерних minority не None и доля < 1, при group_size=2 — у дочерней может не быть миноритария); test_prefix_cyrillic_and_digits (ДЕМО, 2GK — build_group и write_ecp без ошибок); test_group_deterministic.

  • Step 4: Тест ЕЦП: test_ecp_format — лист Выписки, заголовок ровно ["ИНН","КПП","iD ГК","Базовый РФ ГК в CRM","Группа компаний","Организация","ГСК","ГСЗ","Тип"], строк = group_size, колонка ГСК — Входит в ГСК у всех, ИНН/КПП текстом (number_format == "@"), Группа компаний = ГК <prefix>. Остальные колонки (Базовый РФ ГК в CRM, Тип) — как в ecp.py #35; перед реализацией сверить с форматом импорта ядра (/Users/andrey.epifancev/projects/awp-ca/arm-core/api/src/ — поиск Входит в ГСК), расхождение — решение в спеку.

  • Step 5: FAIL → реализация → PASS ($UVRUN python -m pytest synthgen/tests -q).

  • Step 6: Коммит synthgen: словарь компаний, состав группы, выгрузка ЕЦП (#38).

Task 3: Периоды и экономика компаний с внешними контрагентами

Files:

  • Create: synthgen/periods.py, synthgen/counterparties.py, synthgen/economy.py
  • Test: synthgen/tests/test_periods.py, synthgen/tests/test_counterparties.py, synthgen/tests/test_economy.py

Interfaces:

  • Consumes: Config, Group, Ledger, rng_for.

  • Produces:

    • periods.Period — frozen: key (2025-Q1 … 2025-FY), year, quarter, start: date (1 января), end: date, label_1c (1 квартал 2025 г., 1 полугодие 2025 г., 9 месяцев 2025 г., 2025 г.).
    • periods.build_periods(start: str, n: int) -> list[Period]; periods.book_start(ps) -> date (31.12 года перед первым периодом — дата входящих остатков); periods.book_end(ps) -> date.
    • counterparties.OSV_ACCOUNTS = ("01","58","60","62","66","67","76","80","90"); counterparties.WEIGHTS = {"60": 1.6, "62": 1.6, "76": 1.0, "66": 0.1, "67": 0.05, "58": 0.1}; counterparties.plan(cfg: Config, group: Group) -> CounterpartyPlan — CounterpartyPlan.by_file: dict[tuple[str, str], list[Org]] (ключ — (код компании, корень счёта)), capped: bool.
    • economy.build_ledgers(cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan) -> dict[str, Ledger] — книги всех членов без операций внутри группы (их добавляет Task 4 до закрытия месяцев, см. ниже).
    • Порядок построения, на который опирается Task 4: economy.open_books(...) -> dict[str, Ledger] (входящие остатки), economy.post_month(ledgers, cfg, group, plan, year, month) (внешние операции месяца), economy.close_month(ledgers, year, month) (закрытие 26/44 → 90, 90/91 → 99, налог, в декабре 99 → 84); build_ledgers = open_books + цикл месяцев (post_month, затем hooks из аргумента extra: Callable[[dict[str, Ledger], int, int], None] | None = None, затем close_month).
    • Аналитика контрагента в книге: An(cp=<ИНН Org>) для внешних, An(cp=<код члена>) для компаний группы.
  • Step 1: Тесты периодов: test_build_periods_simple (2025-Q1, 4 → ключи 2025-Q1, 2025-Q2, 2025-Q3, 2025-FY, label_1c 1 квартал 2025 г. … 2025 г., start всегда 1 января); test_build_periods_rollover (2025-Q3, 4 → 2025-Q3, 2025-FY, 2026-Q1, 2026-Q2); test_book_start (= 31.12.2024 для обоих).

  • Step 2: Тесты контрагентов: test_mean_matches (group_size=10, periods=4, mean 60 → среднее len по файлам счетов 58/60/62/66/67/76 в пределах ±15 %, capped is False); test_cap_and_flag (mean 150, group_size=40 → 60/62/76 не больше len(externals), 66/67 — только банки, capped честно отражает срез); test_sets_disjoint_from_group (ни одного члена группы и банка в 60/62/76); test_zero_mean (mean 0 → все списки пусты); test_plan_deterministic.

  • Step 3: Тесты экономики (на group_size=3, periods=4, mean 20): test_books_balanced (для каждой книги Σ сальдо всех счетов, включая 000, на любую дату = 0; сальдо 000 после входящих остатков ≠ 0 только против остатков — проверить, что 000 нет оборотов после book_start); test_bs_identity — на конец каждого периода сальдо 90/91/99 = 0 после закрытия месяца, кроме 99 внутри года, и Σ активных = Σ пассивных сальдо (без 000); test_externals_used — у каждой (компания, счёт) из плана с непустым списком в книге есть проводки по ≥ 80 % контрагентов списка за год; test_pareto_shape — по 62 у крупнейшей компании топ-10 % контрагентов дают ≥ 50 % оборота; test_signs_variety — среди сальдо по контрагентам 60/62 на конец года есть оба знака (§5.3, ~3 % противоположных); test_year_rollover (start=2025-Q3, 4 периода: сальдо 90/91/99 на 31.12.2025 = 0, 84 изменился на прибыль года, обороты 2026 начинаются с нуля по 90).

  • Step 4: реализовать. Масштаб выручки — логнормальный по §5.2 (rng_for(seed, "scale", code)), операции — по образцу economy.py #35 (post_month_flow, close_month), но контрагенты — из плана: сумма месяца по счёту раскладывается по контрагентам весами Парето (alpha=1.2, веса фиксированы на компанию×счёт), ~20 % контрагентов — только обороты (полная оплата в месяце), ~5 % — сальдо без оборотов (входящий остаток), ~3 % — обратный знак (аванс). Банки — кредиты 66.01/67.01 и проценты 66.02/67.02 (сверить субсчета с chart.ACCOUNTS). 01 — по субсчету 01.01 (без контрагентов), 90 — 90.01/90.02/90.03/90.07/90.08/90.09.

  • Step 5: PASS всего набора.

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

Task 4: Операции внутри группы и расхождения ВГО

Files:

  • Create: synthgen/intragroup.py
  • Test: synthgen/tests/test_intragroup.py

Interfaces:

  • Consumes: economy.open_books/post_month/close_month или build_ledgers(..., extra=...), Group, Period.

  • Produces:

    • intragroup.VgoPair — frozen: a: str, b: str (коды; a — продавец/займодавец), kind: str (trade|loan), mismatch: Mismatch | None.
    • intragroup.Mismatch — frozen: side: str (код стороны, у которой проводка есть), year: int, period_key: str, amount: Decimal, pct: Decimal (от меньшей стороны сальдо пары на конец периода), above_threshold: bool.
    • intragroup.plan_pairs(cfg: Config, group: Group) -> list[VgoPair] — без расхождений; торговля: каждая упорядоченная пара с вероятностью 0.5 (rng_for(seed,"trade",a,b)); займы: головная → каждая дочерняя.
    • intragroup.build(cfg, group, ps, plan) -> tuple[dict[str, Ledger], list[VgoPair]] — полный построитель книг (вызывает economy с extra): торговля (62.01 ↔ 60.01, объём 5–20 % выручки продавца, частичная оплата), займы (58.03 ↔ 66.03 или 67.03, проценты 76 ↔ 76 / 91), дивиденды (Дт 84 Кт 76.03 у плательщика ↔ Дт 76.03 Кт 91 у получателя во 2 квартале, оплата в том же месяце — решение 11), доли (58.01 у головной ↔ 80.09 у дочерней, миноритарий в 80.09 — входящие остатки), расхождения §5.5.
    • intragroup.pair_balance(ledgers, pair, at: date) -> tuple[Decimal, Decimal] — сальдо у a по b и у b по a по счетам пары.
  • Step 1: Тесты: test_pairs_mirror — для пар без расхождения на конец каждого периода bal_a == -bal_b (точно); test_mismatch_share — group_size=10, доля 0.1 → число пар с расхождением = max(1, floor(0.1 × пар)) в каждом году; доля 0 → ни одной; test_mismatch_mix — при ≥ 2 расхождениях половина pct ∈ [1, 4], половина ∈ [8, 25], при нечётном лишнее — крупное; above_threshold == (pct > 5); test_mismatch_lifecycle — разница сторон = amount во всех периодах года от period_key до конца года и 0 на 31 января следующего года; test_dividends_no_balance — сальдо 76.03 по членам группы на конец каждого периода = 0, обороты 76.03 во 2 квартале есть у обеих сторон; test_shares — 58.01 у головной по дочерней = 80.09 дочерней по головной (точно), миноритарий есть только в 80.09; test_books_still_balanced (как в Task 3, на результате build); test_edge_group_two (group_size=2 — одна пара займа, торговля возможна, генерация без ошибок).

  • Step 2: FAIL → реализация → PASS.

  • Step 3: Коммит synthgen: операции внутри группы и расхождения ВГО (#38).

Task 5: ОСВ в оформлении 1С

Files:

  • Create: synthgen/templates/build_osv_style.py, synthgen/templates/osv_style.json, synthgen/osv.py
  • Test: synthgen/tests/test_osv.py, synthgen/tests/test_osv_parser.py (маркер parser)

Interfaces:

  • Consumes: книги из intragroup.build, Group, Period, counterparties.OSV_ACCOUNTS.

  • Produces:

    • osv.OsvRow — level: int (0 счёт, 1 субсчёт, 2 контрагент), label: str, inn: str | None, шесть сумм od, oc, td, tc, cd, cc: Decimal (сальдо нач. Дт/Кт, обороты Дт/Кт, сальдо кон. Дт/Кт).
    • osv.rows(ledger: Ledger, account: str, period: Period, member: Member, group: Group) -> list[OsvRow] — свёртка книги (родитель = сумма детей, сальдо свёрнуто по стороне); имя контрагента: внешний — Org.name; член группы — Member.name_1c, а в ОСВ компании из group.no_inn у члена из variant_name — вариантное написание; inn — None у компаний из group.no_inn.
    • osv.write(path: Path, member: Member, account: str, period: Period, rows: list[OsvRow], with_inn: bool) -> None.
    • osv.file_name(account: str, period: Period) -> str — сч 60_9 месяцев 2025 г.xlsx, сч 62_2025 г.xlsx.
    • osv_style.json — собирается build_osv_style.py из образца РБ один раз и коммитится: ширины колонок, высоты строк по типам (шапка, заголовок таблицы, строка счёта, строка контрагента), шрифты, заливки F4ECC5/F8F2D8, рамки, объединения строк заголовка и строк данных как смещения от колонки «блок текста» и «блок сумм»; тексты шапки строк 1–10 (кроме организации и периода).
  • Step 1: Тесты оформления (на файле, записанном osv.write в tmp_path): test_header_texts — A1 = имя организации, A2 = Оборотно-сальдовая ведомость по счету 60 за 9 месяцев 2025 г., строки 9–10 содержат Счет/Контрагенты, Сальдо на начало периода, Обороты за период, Сальдо на конец периода, Дебет/Кредит; test_style_matches_sample — шрифт A12 Arial 8, A2 Arial 12 полужирный, заливка A9 FFF4ECC5, A12 FFF8F2D8, у строки контрагента заливки нет, рамки у ячеек таблицы, summaryBelow is False, outline 1/2 у субсчёта/контрагента, отступ 1/2, number_format == "#,##0.00"; test_merges_follow_sample — объединения строк 9–12 совпадают с образцом (для ОСВ без ИНН — буквы те же, что в образце; с ИНН — сдвиг блока сумм на 1 колонку, ИНН в колонке E); test_zero_is_empty (нулевые суммы — пустые ячейки); test_total_row (последняя строка Итого, суммы = сумме строк счёта); test_no_inn_file (у компании из no_inn нет колонки ИНН и нет 10-значных чисел в колонке имени).

  • Step 2: Тесты содержания: test_rows_match_ledger — для одной (компания, 62, период) суммы строк контрагентов равны Ledger.balance/turnover; test_parent_sums; test_group_names — член группы записан name_1c, в ОСВ компании без ИНН ровно один член группы — вариантным именем; test_osv_without_externals (mean 0 — файл 60 валиден, «Итого» есть).

  • Step 3: Приёмка парсером test_osv_parser.py — по образцу synthetic-group/tests/test_osv_parser.py #35: TP_WT из окружения (по умолчанию /Users/andrey.epifancev/projects/awp-ca/.wt/synthetic-group/tabular-parser, venv уже собран; если нет — тест skip), прогон по умолчанию (Config()): каждый файл → status == "done", один документ, счёт = счёт файла, у ОСВ с ИНН у контрагентов-членов группы парсер вернул ИНН, у ОСВ без ИНН — нет; суммы «Итого» документа = суммам rows. Запуск: $UVRUN python -m pytest synthgen/tests -q -m parser.

  • Step 4: FAIL → реализация → PASS (обычный набор и -m parser).

  • Step 5: Коммит synthgen: ОСВ в оформлении 1С (#38).

Task 6: ББ и ОФР по шаблону 1С

Files:

  • Create: synthgen/templates/build_forms_templates.py, synthgen/templates/bb_1c.xlsx, synthgen/templates/ofr_1c.xlsx, synthgen/forms.py, synthgen/forms_xlsx.py
  • Test: synthgen/tests/test_forms.py, synthgen/tests/test_forms_xlsx.py, synthgen/tests/test_forms_parser.py (маркер parser)

Interfaces:

  • Consumes: книги, Member, Period, rng_for.

  • Produces:

    • forms.BALANCE_CODES, forms.INCOME_CODES — 66н из forms.py #35 (BALANCE_FULL["66n"], INCOME_FULL["66n"]) плюс 2530, 2900, 2910 в ОФР.
    • forms.Form — kind: str (BALANCE|INCOME), member, period, columns: list[str] (подписи колонок), values: dict[str, list[Decimal]] (код → значения колонок, тыс. руб. целые; расходы ОФР — отрицательные).
    • forms.balance(ledger, member, period, ps: list[Period], seed: int) -> Form; forms.income(...) -> Form. Колонки ББ: на конец периода, на 31.12 прошлого года, на 31.12 позапрошлого; ОФР: за период, за тот же период прошлого года. Правило §5.6 п. 6: колонка из книги, если дата/период внутри книги (включая 31.12 перед первым периодом = входящие остатки), иначе масштабированные значения (rng_for(seed,"prev",code,kind), множитель 0.8–1.1 к первой книжной колонке, затем пересчёт итогов и подгонка 1600=1700).
    • Свёртка счетов в строки — _classify/balance_rub/income_rub/_plug из forms.py #35 (без FOOD_SUBLINES, S03_DELTA, LONG_TERM_58_03 по кодам #35: долгосрочные займы — 58.03 головной).
    • forms_xlsx.write(form: Form, path: Path) -> None; forms_xlsx.file_name(form) -> str — ББ_9 месяцев 2025 г.xlsx, ОФР_2025 г.xlsx.
    • Шаблоны bb_1c.xlsx/ofr_1c.xlsx — собираются build_forms_templates.py из образцов Межениновской один раз и коммитятся: все формулы заменены пустыми значениями; ячейки правее последней колонки формы очищены; ФИО в подписях, реквизиты (наименование, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, вид деятельности, адрес), аудитор, даты подписания — очищены (генератор пишет свои). Оформление (шрифты, рамки, объединения, высоты, [=0]"-";General, «КОДЫ») сохраняется.
  • Step 1: Тесты расчёта: test_balance_identity — во всех колонках всех ББ прогона по умолчанию 1600 == 1700, итоги разделов = суммам строк; test_balance_matches_ledger — колонка «на конец периода» ББ = balance_rub книги / 1000 с ROUND_HALF_UP построчно (±1 только в строке подгонки); test_first_prior_column_from_book — колонка «на 31 декабря» года перед первым периодом = входящие остатки книги; test_income_matches_ledger — 2110/2120/2210/2220/2320/2330/2340/2350/2400 = обороты 90/91 за период (знаки: расходы < 0); test_2400_flow — 2400 = 2300 + 2410 + 2460 по форме.

  • Step 2: Тесты файла: test_template_sanitized — в bb_1c.xlsx/ofr_1c.xlsx нет формул (str начиная с =), нет строк 7016006635, 85262693, Межениновск, Томская, ФИО подписантов образца (взять из образца в тесте) и нет значений правее последней колонки формы; test_written_file_no_sample_values — то же для файлов прогона; test_codes_and_values — по колонке «Коды» найдены все BALANCE_CODES/INCOME_CODES, значения на месте, ноль отображается форматом [=0]"-";General; test_requisites — организация = Member.name, ИНН = Member.inn, дата шапки и подписи колонок под период; test_style_kept — шрифт заголовка Times New Roman, есть объединения и рамки как в шаблоне.

  • Step 3: Приёмка парсером test_forms_parser.py — по образцу #35 synthetic-group/tests/test_forms_parser.py: каждый файл прогона по умолчанию → status == "done", все коды найдены, значения = Form.values, 1600 == 1700.

  • Step 4: FAIL → реализация → PASS (обычный набор и -m parser).

  • Step 5: Коммит synthgen: ББ и ОФР по шаблону 1С (#38).

Task 7: Сборка набора, README прогона, детерминированность

Files:

  • Create: synthgen/generate.py, synthgen/report.py, synthgen/README.md
  • Modify: synthgen/__main__.py, README.md (корень fixtures — строка дерева про synthgen/)
  • Test: synthgen/tests/test_generate.py

Interfaces:

  • Consumes: всё выше.

  • Produces:

    • generate.generate(cfg: Config, out: Path, *, force: bool = False) -> Manifest — структура каталога §7 (README.md, config.yaml, directories/ecp-<prefix>.xlsx, osv/<КОД>/<период>/…, statements/<КОД>/<период>/…); out непустой без force → FileExistsError; с force — очистка содержимого out.
    • generate.Manifest — files: list[Path], pairs: list[VgoPair], capped: bool.
    • report.render(cfg, group, pairs, plan, manifest) -> str — README прогона §7: настройки, состав группы (код, имя, ИНН, доля головной, миноритарий), компании без ИНН и вариантное имя, пары ВГО с расхождениями (стороны, вид, период, сумма, %, «ниже/выше порога 5 %»), число контрагентов по файлам (мин/среднее/макс по счетам), «среднее ограничено словарём», если capped.
    • __main__.main — вызывает generate, печатает путь и сводку (файлов, пар, расхождений), код 0; FileExistsError → сообщение и код 3.
  • Step 1: Тесты: test_layout (прогон по умолчанию: есть все разделы §7, ОСВ = 5 компаний × 4 периода × 9 счетов, кроме пустых по правилу 1С — пустой файл всё равно пишется с «Итого»; ББ и ОФР = 5 × 4 × 2); test_determinism — два прогона с одним seed в разные каталоги побайтно равны по всем файлам; разный seed → хотя бы один файл различается; test_out_not_empty (без force — FileExistsError, CLI код 3; с force — старых файлов нет); test_edge_configs — параметризованный: group_size=2; group_size=40, periods=1, osv_counterparties_mean=10; no_inn_companies=group_size; vgo_mismatch_share=1; start=2025-Q4, periods=2 — generate без ошибок, README непустой; test_readme_lists_mismatches (каждая пара с расхождением из Manifest.pairs упомянута в README с процентом); test_large_run_time (маркер slow, по умолчанию пропускается: group_size=15, periods=6, osv_counterparties_mean=150 — меньше 300 с).

  • Step 2: FAIL → реализация → PASS.

  • Step 3: synthgen/README.md — назначение, настройки §3 (таблица), пример запуска, структура результата, как загрузить на стенд (импорт ЕЦП, затем документы по компаниям), ограничения (не оракул, только 66н, среднее ≤ 150, коллизия хеша префиксов), как пересобрать словарь и шаблоны. Строка в корневом README.md.

  • Step 4: Крупный прогон и приёмка: $UVRUN python -m synthgen --set group_size=15 --set periods=6 --set osv_counterparties_mean=150 --out /private/tmp/claude-501/synthgen-large --force — время в отчёт; затем TP_WT=… $UVRUN python -m pytest synthgen/tests -q -m parser — PASS; полный набор — PASS.

  • Step 5: Коммит synthgen: сборка набора, README, детерминированность (#38).