From 74eb397a938bd69057be8ddbc58d292cfcf172d0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 23:25:44 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-11-12-synthgen.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-12-synthgen.md diff --git a/docs/superpowers/plans/2026-11-12-synthgen.md b/docs/superpowers/plans/2026-11-12-synthgen.md new file mode 100644 index 0000000..abc0418 --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-synthgen.md @@ -0,0 +1,363 @@ +# Настраиваемый генератор синтетики ГСК — план реализации + +> **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 ` (или фактическая модель исполнителя). +- Не делать 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` (`АО " <корень>"`), `name_1c: str` + (` <корень> АО` — как 1С пишет контрагента), `inn: str`, `kpp: str`, `is_head: bool`, + `head_share: Decimal | None` (доля головной, у головной `None`), `minority: Org | None`. + - `group.Group` — frozen: `prefix`, `name` (`ГК `), `gk_id` (`-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 == "@"`), `Группа компаний` = `ГК `. + Остальные колонки (`Базовый РФ ГК в 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-.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)`.