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

364 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Настраиваемый генератор синтетики ГСК — план реализации
> **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)`.