diff --git a/.gitignore b/.gitignore index 78ec9d7..7613973 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .DS_Store ~$* *.tmp +.superpowers/ +out/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 275e611..4cbb787 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ ``` osv/{rb,schastl}/<ГГГГ-Qn | ГГГГ-FY>/ ОСВ по счетам, исходные имена файлов (разложены по шапке, не по имени) osv/synthetic/ синтетика s01–s19 для форматов, которых нет в реальных данных (Срез 2a); генератор и описание — osv/synthetic/README.md +synthgen/ настраиваемый генератор синтетической ГСК (ОСВ, ББ, ОФР, ЕЦП) для нагрузочных и демо-прогонов; запуск `python -m synthgen`, описание — synthgen/README.md osv-dkb//<период>/ ОСВ шести организаций из пакета ДКБ 06.10.2026 (Цимлянский, НКЗ, Новоросметалл, НПЗ, Приманыческий, Агрокомплекс): многолистовые книги, needs_check, 50 тыс. строк — свои тесты (test_osv_dkb.py) и golden, см. osv-dkb/README.md analysis-reports//<период>/ отчёты 1С «Анализ счета» по контрагентам группы из того же пакета (не ОСВ), см. README 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)`. diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md new file mode 100644 index 0000000..9601284 --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -0,0 +1,307 @@ +# Настраиваемый генератор синтетики ГСК — проект + +Issue: RSHB/arm-ka-fixtures#38. Ветка `feat/38-synthgen`. Дата: 12.11.2026. + +## 1. Зачем + +Для ручного тестирования, демонстрации и проверки на объёме нужен набор первички группы компаний, который +**выглядит как выгрузки из 1С** и **внутренне согласован**: ОСВ по счетам, ББ и ОФР каждой компании построены из одних и +тех же проводок, поэтому сходятся между собой, а операции между компаниями группы дают пары ВГО и шахматку, на которые +есть что посмотреть. Набор должен генерироваться скриптом под нужный размер, а не собираться руками. + +Реальные фикстуры (`osv/rb`, `osv/schastl`, `osv-dkb/`) — две-три компании, неполные периоды, реальные ИНН. Контрольный +набор #35 (`synthetic-group/`, 7 компаний, ручные сценарии, оракул) решает другую задачу — проверку алгоритмов на +известных случаях — и для объёма не годится: состав фиксирован, контрагентов единицы, оформление не похоже на 1С. + +## 2. Цели, не-цели, анти-цели + +**Цели** +- Один скрипт генерирует ОСВ, ББ, ОФР по всем компаниям и периодам и файл состава ГК для импорта ЕЦП. +- Семь настроек (§3) из конфигурационного файла с переопределением из командной строки. +- Файлы по виду — выгрузки 1С (§6). +- Данные согласованы (§5.6) и детерминированы: одинаковые настройки и `seed` → побайтно одинаковый набор. + +**Не-цели (в этой задаче)** +- Оракул, ожидаемые результаты (`expected/`), ручные сценарии вида R-/P-/V-/G-/S- из #35. +- Сканы PDF и ответы ИС Сканирование. +- ББ/ОФР по ФСБУ 4/2023 и упрощённые формы — только 66н. +- Компании ГК вне ГСК, несколько ГК в одном прогоне, расшифровки, КФО клиента. +- Загрузка на стенд (для этого есть `load.py` в #35; приспособить его — отдельная задача). + +**Анти-цели (провал, даже если «работает»)** +- Реальные ИНН или имена реальных организаций в наборе (в том числе остатки реквизитов из образцов 1С, §6.2). +- ОСВ, не сходящаяся с ББ/ОФР той же компании; ББ, где 1600 ≠ 1700. +- Набор, который парсер `tabular-parser` (ОСВ) или разбор ББ/ОФР в Excel не принимает из-за оформления генератора. +- Пересечение компаний двух прогонов с разными префиксами на одном стенде (имя или ИНН). + +## 3. Настройки + +Файл YAML (`--config`), любой ключ переопределяется `--set ключ=значение`. Неизвестный ключ или значение вне диапазона — +ошибка до начала генерации. + +| # | Ключ | Смысл | По умолчанию | Диапазон | +|---|---|---|---|---| +| 1 | `group_size` | число компаний в ГК (все входят в ГСК) | 5 | 2–40 | +| 2 | `prefix` | префикс ГК: добавляется к названию группы и её компаний (§4.2), задаёт диапазон ИНН компаний группы | `SYN` | 2–12 символов, буквы/цифры, без пробелов | +| 3 | `periods` | число отчётных периодов | 4 | 1–12 | +| 4 | `start` | первый период, `ГГГГ-Qn` | `2025-Q1` | 2020-Q1 … 2030-Q4 | +| 5 | `osv_counterparties_mean` | среднее число **внешних** контрагентов в одной ОСВ (матожидание, §5.3) | 60 | 0–150 (ограничено словарём, решение 9) | +| 6 | `no_inn_companies` | сколько компаний группы выгружают ОСВ без ИНН контрагентов | 1 | 0–`group_size` | +| 7 | `vgo_mismatch_share` | доля пар ВГО с расхождением сторон | 0.1 | 0–1 | +| — | `seed` | зерно случайности | 1 | целое | +| — | `out` | каталог результата (или `--out`) | `./out/` | — | + +Пример: `uv run python -m synthgen --config synthgen/examples/default.yaml --set group_size=12 --set prefix=DEMO2 --out /tmp/demo2`. + +## 4. Словарь компаний и состав группы + +### 4.1 Словарь +Закоммиченный файл `synthgen/dictionary/companies.csv`: **400** вымышленных организаций — +`name;form;inn;kpp;kind` (`form` ∈ ООО/АО/ПАО/ИП; `kind` — отрасль для правдоподобия: агро, торговля, логистика, +производство, услуги, финансы, строительство). Около 10 % — ИП (ФИО + «ИП», ИНН 12 знаков, КПП пусто). Имена +собираются один раз детерминированным скриптом `synthgen/dictionary/build.py` из списков корней/отраслевых слов и +проверяются на совпадение с реальными: имена не должны совпадать с организациями из `fixtures/directories/` и фикстур ОСВ. +Из 400 записей **30 — банки** (`kind=банк`, АО/ПАО): они — кредиторы по 66/67 и только там. +ИНН — как в #35: регион `00` (не существует), верная контрольная сумма; номера словаря — диапазон `0100001–0109999` +(**не пересекается** с ИНН #35, где номера 1–99, решение 10). + +Внешние контрагенты всех ОСВ берутся из словаря. Компании группы — тоже из словаря (§4.2), и **такая запись в этом +прогоне внешним контрагентом не бывает**. + +### 4.2 Компании группы и префикс +- Из словаря выбираются `group_size` записей с `form` ∈ ООО/АО (не ИП); головная — АО. +- Имя компании группы: `<форма> " <имя из словаря>"`, например `АО "DEMO2 Северная Нива"`. Название ГК: `ГК `. +- ИНН компании группы — **не из словаря**, а из диапазона префикса: номер = `1000000 + 100 × h(prefix) + i`, где `h` — + детерминированный хеш префикса (SHA-256 → целое) в `0…89999`, `i` — порядковый номер компании (< 100). Так ИНН компаний + группы не совпадают с ИНН словаря, #35 и групп с другими префиксами (коллизия хеша ~1/90000; README её оговаривает). +- КПП — 9 цифр, как в #35: `00` + двузначный номер компании + `01001` (у ИП КПП нет). + +## 5. Модель данных + +### 5.1 Периоды +Периоды — `periods` кварталов подряд начиная с `start`. Каждый отчётный период — **нарастающим итогом с начала года**, +как выгружает 1С: Q1 → «1 квартал», Q2 → «1 полугодие», Q3 → «9 месяцев», Q4 → «год» (`ГГГГ-FY`). Пример: `start=2025-Q3`, +`periods=4` → `2025-Q3`, `2025-FY`, `2026-Q1`, `2026-Q2`. + +Книга проводок компании ведётся с 1 января года первого периода (входящие остатки датированы **31 декабря предыдущего +года** проводкой против вспомогательного счёта `000`, как в #35) до конца последнего периода. ОСВ показывают `000` только +как источник входящих остатков — в файлы ОСВ счёт `000` не выгружается. Переход года: сальдо на 31.12 становится входящим остатком следующего +года, ОФР обнуляется закрытием 90/91 на 99 и 99 на 84 (реформация) в последний день года. + +### 5.2 Экономика компании +Каждой компании назначается профиль из отрасли словаря и масштаб (выручка за год — логнормально, медиана ~1 млрд руб., +разброс ×10 в обе стороны; головная — холдинг с небольшой выручкой и крупными вложениями 58.01). По профилю генерируются +помесячные операции: +- продажи (62/90), закупки (60/10/41/20), себестоимость (90.02), коммерческие и управленческие расходы (44/26); +- ОС и амортизация (01/02/08), денежные средства (51), НДС (19/68), налог на прибыль (68.04/99), зарплата (70/69) — + минимально, чтобы ББ был правдоподобным; +- банковские кредиты (66/67 с внешним банком из словаря), прочие расчёты (76), прочие доходы/расходы (91). + +Деньги — `Decimal`, `ROUND_HALF_UP`, копейки в ОСВ; float только при записи в ячейку. + +### 5.3 Внешние контрагенты в ОСВ +ОСВ генерируются по счетам **01, 58, 60, 62, 66, 67, 76, 80, 90** с субсчетами (§6.1): чек-лист `BASIC` +(01, 58, 60, 62, 66, 67, 76, 90 — `docs/context/01-project-overview.md`) плюс 80 (уставный капитал — +нужен правилу гудвилла 58.01 ↔ 80, ДП §4) (решение 7). Счета 01 и 90 по контрагентам не разворачиваются (01 — по +субсчетам, 90 — по субсчетам 90.01/90.02/…/90.09; у компании группы выручка от других компаний группы видна в ОСВ 62 +продавца). 80 — по участникам (головная и миноритарий). Для каждой пары +(компания, счёт с контрагентами) генератор выбирает число внешних контрагентов `n` и набор `n` записей словаря; этот +набор постоянен по всем периодам компании (контрагент может иметь нулевое сальдо и обороты в отдельном периоде — тогда +строки нет, как в 1С). + +`n` — отрицательное биномиальное с матожиданием `mean × w(счёт)` (дисперсия ≈ 2 × матожидание), где веса +`w`: 60 — 1.6, 62 — 1.6, 76 — 1.0, 66 — 0.1, 67 — 0.05, 58 — 0.1, 80 — 0 (только участники). Веса нормируются так, +что **среднее `n` по всем файлам ОСВ счетов 58, 60, 62, 66, 67, 76 = `osv_counterparties_mean`**. Потолки: 60/62/76 — +словарь без банков и компаний группы (≈ 370 − `group_size`), 66/67 — 30 банков. Если потолок срезал выборку, недобор +перераспределяется на остальные файлы того же счёта; тест среднего (допуск ±15 % при числе файлов ≥ 20) применяется, +когда потолок не сработал ни разу, иначе README прогона пишет «среднее ограничено словарём» (решение 9). + +Обороты распределяются по контрагентам по Парето (несколько крупных, длинный хвост мелких), сумма = обороты счёта из +экономики §5.2. У части контрагентов (~20 %) только обороты без сальдо, у ~5 % — сальдо без оборотов (старая +задолженность). Знак сальдо по контрагенту — по смыслу счёта, у ~3 % в 60/62 — противоположный (аванс/переплата), +как в реальных ОСВ. + +### 5.4 Операции внутри группы +- Головная компания владеет долями всех остальных: 58.01 у головной по каждой дочерней ↔ 80 у дочерней по головной + (уставный капитал; доля головной = её часть в 80). Доли 51–100 %, у одной-двух дочерних — миноритарий из словаря + (внешний участник, только в 80). Вклады — входящие остатки, движения в периодах нет. +- Торговля: каждая компания с вероятностью 0.5 продаёт каждой другой (62 у продавца ↔ 60 у покупателя); объём — + 5–20 % выручки продавца; расчёты гасятся частично, на конец периода остаётся сальдо. +- Займы: головная выдаёт займы дочерним (58.03 ↔ 66.03/67.03), с процентами (76 ↔ 76, 91). +- Дивиденды: дочерние объявляют головной раз в год во 2 квартале (Дт 84 Кт 76.03 у плательщика ↔ Дт 76.03 Кт 91 у получателя — стороны правила ОФР 2310, ДП §4) + и **выплачивают в том же месяце** — на конец периода остатка нет, обороты видны в ОСВ 76 обеих сторон. Так дивиденды не + дают пар ВГО по сальдо и не создают ложных односторонних пар; дивиденды в расхождения §5.5 не попадают (решение 11). +- Каждая операция проводится **в двух книгах одновременно** симметрично (Дт у одной, Кт у другой — по смыслу счетов). + +### 5.5 Расхождения ВГО +Пара ВГО — (компания A, компания B, вид расчёта: торговля / заём). Доля `vgo_mismatch_share` пар (округление вниз, но +не меньше 1, если доля > 0 и пар ≥ 1) получает расхождение: в последнем месяце **первого** периода года, где у пары есть +сальдо, одна сторона проводит накладную (или начисление процентов), которой нет у другой («в пути»). Величина — доля от меньшей стороны сальдо пары на конец этого периода, **вперемешку** (решение владельца 12.11.2026): +половина пар с расхождением (чётные по порядку выбора) — 1–4 % (ниже порога 5 % ДП §4: ядро сводит по `min` +автоматически), другая половина — 8–25 % (выше порога: пара подсвечивается и ждёт корректировки аналитика). При +нечётном числе пар лишняя — крупная. README прогона пишет для каждой пары процент и сторону порога. Расхождение держится до конца +года и снимается в январе следующего года проводкой второй стороны (накладная дошла) — в следующем году пара снова +выбирается заново. Остальные пары сходятся точно. + +### 5.6 Согласованность (инварианты, проверяются тестами) +1. В каждой книге Σ Дт = Σ Кт по каждой проводке; сальдо `000` после входящих остатков — ноль. +2. ОСВ компании за период = свёртка её книги (сальдо на начало, обороты, сальдо на конец) по счёту/субсчёту/контрагенту; + строка родителя = сумма детей. +3. ББ = сальдо книги на конец периода по правилам свёртки счетов в строки 66н (из `forms.py` #35, §8); ОФР = обороты + 90/91/99 за период нарастающим итогом. В тыс. руб. с `ROUND_HALF_UP` построчно, итоги — суммы округлённых строк; + 1600 = 1700 обеспечивается подгонкой ±1 тыс. в одной строке (как `_plug` в #35). ОСВ — в рублях. +4. Пары ВГО без расхождения: сальдо у A по B = −(сальдо у B по A) по соответствующим счетам (62↔60, 58.03↔66.03/67.03, + 76↔76); с расхождением — разница ровно на сумму накладной «в пути» §5.5 во всех периодах от её проводки до конца + года и ноль после января. +6. Колонка ББ «На 31 декабря» года, предшествующего первому периоду, = входящие остатки книги (= «Сальдо на начало» + ОСВ первого года); колонки ранее этого (ББ «на 31 декабря» позапрошлого года, ОФР за прошлый год, если прошлого года + нет в книге) — масштабированные значения профиля без требования согласованности (решение 5). +5. Компания группы в ОСВ другой компании группы записана тем же именем, что в ЕЦП, и тем же ИНН, кроме §6.1 (без ИНН). + +## 6. Вид файлов + +### 6.1 ОСВ +Формат «ОСВ по счёту» 1С 8.3 (БП 3.0) по образцу `fixtures/osv/rb/2024-Q1/сч 60_ 1 кв 2024 г.xlsx`: лист `Лист_1`, +строки 1–10 — шапка (организация, «Оборотно-сальдовая ведомость по счету NN за <период>», «Выводимые данные…», +«Параметры: Валюта БУ, НУ: RUB», двухуровневый заголовок «Счет / Контрагенты», «Сальдо на начало периода / Обороты за +период / Сальдо на конец периода», «Дебет / Кредит»), строка 12 — счёт, далее субсчета (уровень группировки 1, отступ 1) +и контрагенты (уровень 2, отступ 2), внизу «Итого». Оформление — **как в образце**: Arial 8 (данные) / 9–10 (шапка) / +12 полужирный (заголовок), заливка шапки `F4ECC5`, строк счетов и субсчетов `F8F2D8`, тонкие рамки у всех ячеек +таблицы, фиксированные высоты строк, сетка узких колонок A…W с объединением ячеек (колонка «Счет» — A:D, суммы — +E:G, H:I, J:K, L:N, O:P, Q:… по образцу), формат `#,##0.00`, нулевые суммы — пустые ячейки, итог над детьми +(`summaryBelow=False`). Сетку колонок, ширины и объединения генератор берёт из образца программно (читает образец при +сборке шаблона), а не набирает вручную. + +ИНН контрагента у компаний **с ИНН** — отдельная колонка после наименования (решение 3), у компаний из +`no_inn_companies` — колонки нет. Парсер ищет колонку ИНН строго между колонкой текста и первой колонкой сумм +(`tabular-parser/api/src/parser/detect.py`, `_inn_col`), поэтому в ОСВ с ИНН сетка образца **сдвигается**: наименование +A:D, ИНН — новая колонка E (ширина ~11), суммы — со следующей колонки с той же внутренней сеткой объединений, что в +образце. Шаблон хранит сетку как «блок текста + опциональная колонка ИНН + блок сумм», а не как фиксированные буквы. В ОСВ без ИНН компании группы записаны: большинство — тем же именем, что в ЕЦП +(автоматическое сопоставление по имени), **одна** компания группы на каждую такую ОСВ-компанию — немного другим +написанием (без кавычек и формы в конце, «Северная Нива DEMO2 ООО», как в 1С у реальных контрагентов) — её аналитик +сопоставит вручную (решение 4). + +Имя файла: `сч NN_<период 1С>.xlsx`, например `сч 60_9 месяцев 2025 г.xlsx`, `сч 62_2025 г.xlsx`. + +### 6.2 ББ и ОФР +Шаблон — реальная выгрузка 1С на листе `TDSheet` (форма 66н): `fixtures/statements-xlsx/mezheninovskaya/2025-Q3/ +1_Форма1_бух_09.xlsx` (ББ) и `1_Форма2_бух_09.xlsx` (ОФР). Генератор **копирует книгу образца** (стили, объединения, +шрифты Times New Roman, высоты строк, рамки, формат нулей `[=0]"-";General`, блок «КОДЫ») и заменяет: +- реквизиты: наименование, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, вид деятельности, адрес — вымышленные; **все** прочие ячейки + шапки с данными образца (аудитор, его ИНН/ОГРН, подписи, даты подписания) очищаются; тест проверяет, что ни одна строка + из образца, кроме неизменяемых текстов формы, не попала в файл; +- даты и заголовки колонок периодов («За 9 месяцев 2025 года», «На 31 декабря 2024 года», «На 31 декабря 2023 года» — + для ББ; текущий и прошлый год — для ОФР); +- значения — по кодам строк (колонка «Коды»), а не по номерам строк образца. + +Образец правили в Excel, поэтому при сборке шаблона (`synthgen/templates/build_templates.py`, один раз, результат +коммитится) генератор: заменяет **все** формулы значениями (итоги 1100/1200/1600/1300/1400/1500/1700, 2100/…/2500 и +заголовки второй страницы — генератор пишет числа сам); удаляет рабочие формулы и значения вне формы (ячейки правее +последней колонки формы); очищает ФИО в подписях и ставит вымышленные; оставляет в ОФР строки 2530, 2900, 2910 (2530 — +из книги, если есть, иначе «-»; 2900/2910 — «-»). Значения в файле — в тыс. руб. **целыми** (решение 12), расходы ОФР — +**отрицательными** числами, как в образце (в скобках их не пишет и 1С). Набор строк — 66н из `forms.py` #35 плюс +2530/2900/2910; `edition_of` из #35 не копируется (всегда 66н). + +Колонки сравнительных периодов (прошлый год / позапрошлый год), если книга их не покрывает (период до начала книги), +заполняются масштабированными значениями того же профиля (решение 5) — так у файла нет пустых колонок, но и +согласованность для них не требуется. + +Имена файлов: `ББ_<период 1С>.xlsx`, `ОФР_<период 1С>.xlsx`. + +### 6.3 Состав ГК (импорт ЕЦП) +`directories/ecp-.xlsx` в формате файла #35 `synthetic-group/directories/ecp-synthetic-group.xlsx` (он +прошёл импорт в ядро; реальная выгрузка `fixtures/directories/ecp-export-russkiy-biskvit.xlsx` отличается — лист +`Выписки_01.04.26`, без КПП, имена «ЭКО ВОЛОГДА, АО» — импорт ядра принимает оба вида): лист `Выписки`, колонки +`ИНН, КПП, iD ГК, Базовый РФ ГК в CRM, Группа компаний, Организация, ГСК, ГСЗ, Тип`; все компании — «Входит в ГСК»; +`iD ГК` = `-000001`; ИНН/КПП текстом. Перед реализацией исполнитель сверяет формат с эндпоинтом импорта +(`arm-core`, `POST /api/v1/directories/import`) и с `ecp.py` #35. + +## 7. Результат прогона + +``` +/ + README.md — настройки прогона, состав группы (имя, ИНН, доля головной), список пар ВГО с + расхождениями (стороны, вид, сумма), какие компании без ИНН и какой контрагент + записан «другим именем», число контрагентов по файлам — не оракул, а журнал генератора + config.yaml — фактические настройки (с seed) + directories/ecp-.xlsx + osv/<КОД>/<период>/сч NN_….xlsx + statements/<КОД>/<период>/ББ_….xlsx, ОФР_….xlsx +``` +`<КОД>` — короткий латинский код компании (`C01`…`Cnn`, головная — `C01`); полное имя — в README и ЕЦП. + +## 8. Код и переиспользование + +- Пакет `synthgen/` в корне fixtures (рядом с `synthetic-group/`), запуск `uv run --with openpyxl==3.1.5 --with pyyaml + python -m synthgen …`. Тесты — `synthgen/tests/`, pytest. +- Ветка #35 ещё не слита, поэтому нужные модули **копируются** в `synthgen/` (с пометкой источника в docstring), не + импортируются из `synthetic-group/`: `inn.py`, `ledger.py`, `xlsx.py` (детерминированное сохранение), `chart.py` + (субсчета из seed arm-core; добавить недостающие — 80.09, 84, 91.09, 99 — сверив с + `arm-core` seed `chart_of_accounts.csv`), правила свёртки счетов в строки ББ/ОФР из `forms.py`, запись ЕЦП из `ecp.py`. + Источник — worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/synthetic-group/fixtures/synthetic-group/gen/`. + Дедупликация — после слияния обоих (follow-up). +- Сгенерированные наборы **в git не коммитятся** (`out/` в `.gitignore`); коммитятся код, словарь, пример конфигурации и + шаблоны, собранные из образцов (без реквизитов образца). + +## 9. Приёмка + +- `pytest` зелёный: инварианты §5.6, распределение §5.3 (среднее в допуске), доля расхождений §5.5, число ОСВ без ИНН, + ИНН из диапазона префикса и с верной контрольной суммой, отсутствие реквизитов образцов, детерминированность + (два прогона с одним `seed` → побайтно равны; разный `seed` → различаются), ошибки конфигурации. +- Прогон по умолчанию и прогон «крупный» (`group_size=15, periods=6, osv_counterparties_mean=150`) проходят парсер ОСВ + `tabular-parser` (зеркальный worktree, как в #35) без ошибок; разбор ББ/ОФР в Excel находит все коды и сходит + 1600 = 1700. +- Время крупного прогона — до 5 минут на ноутбуке разработчика. + +## 10. Что оставлено исполнителю + +Выбор распределений внутри указанных рамок, алгоритм генерации имён словаря, внутренняя структура пакета, формат +README прогона, точные субсчета внутри перечисленных счетов (из `chart.py`). + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 12.11.2026 | Новый пакет `synthgen/` отдельно от `synthetic-group/` (#35), модули #35 копируются | #35 не слита; разные задачи (контрольный набор vs объём); импорт между каталогами связал бы их ветки | дублирование кода до follow-up | +| 2 | 12.11.2026 | Все компании ГК входят в ГСК; `group_size` — одно число | владелец назвал «размер ГСК/ГК» одной настройкой | нет сценария «член ГК вне ГСК» — добавить настройку позже | +| 3 | 12.11.2026 | ИНН — отдельная колонка после наименования | в образцах 1С ИНН нет; колонка уже принимается парсером (#35, приёмка tabular-parser); в имени ИНН сломал бы сопоставление по имени | если в реальных выгрузках ИНН иначе — поменять запись ОСВ | +| 4 | 12.11.2026 | В ОСВ без ИНН одна компания группы — с другим написанием имени | владелец: «аналитик сопоставит малую часть контрагентов» | если нужно больше ручных сопоставлений — сделать настройкой | +| 5 | 12.11.2026 | ББ «на 31.12» года перед первым периодом — из книги (входящие остатки); более ранние сравнительные колонки — масштабированные значения профиля | первая колонка должна совпадать с «Сальдо на начало» ОСВ того же набора; глубже книги нет, пустые колонки нетипичны для 1С | аналитик может заметить несоответствие позапрошлогодних цифр | +| 6 | 12.11.2026 | Только форма 66н | образец 1С с 66н есть (Межениновская, 9 мес. 2025); ФСБУ 4/2023 — не-цель | годовая отчётность 2025+ по новой форме не покрыта | +| 7 | 12.11.2026 | Счета ОСВ: 01, 58, 60, 62, 66, 67, 76, 80, 90 | чек-лист `BASIC` (01-project-overview) + 80 (правило гудвилла 58.01↔80); 02 не входит в `BASIC` | версия `EXTENDED` покажет пробелы чек-листа — добавить счета позже | +| 9 | 12.11.2026 | `osv_counterparties_mean` ≤ 150, потолки по словарю, перераспределение недобора | владелец задал словарь 200–400; при среднем 300 потолок 60/62 срезает выборку и среднее не выполняется | для большего объёма нужен словарь крупнее — расширить `build.py` | +| 10 | 12.11.2026 | ИНН словаря — номера 0100001–0109999, компании группы — 1000000+ по хешу префикса | не пересекаться с #35 (номера 1–99) и между префиксами на одном стенде | коллизия хеша префиксов ~1/90000 | +| 11 | 12.11.2026 | Дивиденды выплачиваются в месяце объявления, в пары ВГО по сальдо и расхождения не попадают | дивиденды проводятся через 76.03 у обеих сторон (стороны правила ОФР 2310) и гасятся в месяце объявления; остаток на дату ББ дал бы пару по 76.03 вне доли расхождений (ДП §4, правила 5–8) — остаток дал бы ложную одностороннюю пару | пары по дивидендам не видны в шахматке по сальдо (обороты видны в ОСВ 75/76) | +| 12 | 12.11.2026 | ББ/ОФР в тыс. руб. целыми, расходы ОФР отрицательными | так выглядит образец 1С; дробные тысячи в образце — следствие ручной правки | если ядро ждёт копейки в тысячах — поменять запись | +| 8 | 12.11.2026 | Значения по умолчанию §3 (5 компаний, `SYN`, 4 периода с 2025-Q1, 60 контрагентов, 1 без ИНН, доля 0.1) | разумный стартовый объём: «есть что смотреть», прогон быстрый | никакой — меняется настройкой | +| 13 | 12.11.2026 | Тесты жизненного цикла расхождений ВГО проверяют снятие 31 января, только если январь следующего года внутри периодов книги; иначе — только удержание до конца года (R-1) | книга кончается последним периодом, января после него нет | непроверенное снятие расхождения в хвосте набора | +| 14 | 12.11.2026 | Скрипт сборки шаблонов ББ/ОФР называется `build_forms_templates.py` (как в плане; §6.2 называл `build_templates.py`) (R-2) | отличать от `build_osv_style.py` | нет | +| 15 | 12.11.2026 | Префикс ГК — буквы латиницы и кириллицы + цифры, 2–12 символов (`config._PREFIX_RE`) | §3 говорит «буквы/цифры» без уточнения алфавита; название группы русское | префикс с кириллицей не пройдёт, если ядро ждёт только латиницу — сузить регулярное выражение | +| 16 | 12.11.2026 | Зерно `rng_for`/`stable_hash`: первые 8 байт SHA-256 читаются как big-endian | §8/§3 не фиксируют порядок байт; важна только стабильность между запусками | смена порядка меняет все наборы для того же seed одноразово — перегенерировать образцы | +| 17 | 12.11.2026 | `synthgen/dictionary/` — пакет с `__init__.py` (там `Org`, `load`), модуля `dictionary.py` нет | `python -m synthgen.dictionary.build` требует пакет; `dictionary.py` рядом с каталогом `dictionary/` давал бы конфликт имён; импорт `synthgen.dictionary` не меняется | нет | +| 18 | 12.11.2026 | Вариантное написание члена группы в ОСВ без ИНН: перестановка `<корень> <форма>` без кавычек (`group.variant_spelling`); вариант выбирает `rng_for(seed, "group", prefix)` среди остальных членов | нужно написание, которое не совпадёт с `name` и `name_1c` посимвольно, но читается человеком; точный механизм сопоставления ядра по имени не проверялся | если ядро нормализует порядок слов и сопоставит автоматически — ручного сопоставления не будет, поменять правило варианта | +| 19 | 12.11.2026 | Доли головной: дочерняя без миноритария — `1.00`; у 1–2 дочерних (при `group_size=2` — у единственной) доля 0.51–0.99, миноритарий — внешняя организация словаря (юрлицо не банк) | §4 тестов требует `head_share ∈ [0.51, 1.00]` и миноритария у 1–2; миноритарий остаётся внешним контрагентом, источник долей — только данные группы | миноритарий не как контрагент в ОСВ — не будет связи, исключить из `externals` | +| 20 | 12.11.2026 | В ЕЦП «Базовый РФ ГК в CRM» — `Ростовский региональный филиал`, `Тип` — `ЮЛ`, как в #35; имя группы в колонке «Группа компаний» — `ГК ` (в #35 было без «ГК») | импорт ядра принимает любые значения (обязательны ИНН, iD ГК, Группа компаний, Организация, Тип); `ГК ` задано §4.2 | нет | +| 21 | 12.11.2026 | Веса контрагентов — детерминированная кривая Лоренца Парето (доля верхних p = p^(1−1/α), α=1.2, шум ±15 %), а не выборка `paretovariate` | выборка Парето на 43 контрагентах давала долю верхних 10 % от 23 % до 97 % по зерну; тест формы (≥ 50 %) стал бы случайным | распределение чуть «глаже» настоящей выборки Парето | +| 22 | 12.11.2026 | `Ledger`: индекс накопленных итогов по (счёт, аналитика) для `balance` на дату не раньше последней проводки; `Posting`/`An` — `slots=True` | закрытие месяца спрашивает сальдо по 90/91/68/99 после каждого месяца; полный проход по проводкам давал O(N²) на крупном прогоне (600 тыс. проводок при 15 компаниях, 6 периодах, 150 контрагентах) | запрос сальдо на прошлую дату всё ещё идёт проходом по проводкам (кэшируется) | +| 23 | 12.11.2026 | В `chart.ACCOUNTS` добавлены 01.01, 67.01, 67.02; названия всех субсчетов сверены с seed arm-core (`api/seed/core/chart_of_accounts.csv`) — расхождений нет (T1.7) | экономика использует 01.01 и кредиты/проценты 67.01/67.02, которых не было в копии #35 | нет | +| 24 | 12.11.2026 | Поведение контрагентов: ~72 % «обычные» (оплата в следующем месяце), ~20 % «только обороты» (оплата в месяце), ~5 % «только сальдо» (входящий остаток), ~3 % «аванс» (62.02/60.02 обратного знака, не меньше одного при n ≥ 8); трое крупнейших — всегда обычные. Для 58 «обычные» и «только сальдо» — остаток займа 58.03, «только обороты» — выдан и возвращён в месяце. Без внешних контрагентов (mean = 0) операции идут без аналитики контрагента | §5.3 задаёт доли, но не привязку к субсчетам; механика — из #35 | доли в ОСВ могут отличаться от реальной выгрузки банка — поменять константы в `economy._classes` | +| 25 | 12.11.2026 | Головная в `economy` — холдинг: выручка ~50 млн ₽/год, ОС 1.5× выручки, закупок 10 %; вложений 58.01 и участников в 80 нет — 80 проводится без аналитики на 2 % годовой выручки (их добавит Task 4 вместе с операциями внутри группы) | вложения 58.01 по смыслу — операции внутри группы (гудвилл 58.01 ↔ 80 дочерних), не внешняя экономика | Task 4 должен перепровести 80 по участникам и 58.01 головной | +| 26 | 12.11.2026 | Входящие остатки групповых операций (Task 4): капитал дочерней — 80.09 по участникам (головная `An(cp=<код>)`, миноритарий `An(cp=<ИНН>)`, доли по `head_share`), у головной 58.01 по каждой дочерней на ту же сумму; займы головной — 58.03 ↔ 66.03 или 67.03 (по зерну `loan-term`) на 3–6 % годовой выручки дочерней; 84 пересчитывается из итога строк после хука (`economy.open_books(opening_hook=…)`). Собственный 80 головной остаётся без аналитики | §5.4 говорит «80», бриф — «80.09»; по ДП §4 правило гудвилла берёт 80/83, 80.09 входит; участники головной в группе неизвестны | если ядру нужен 80 (без .09) — сменить субсчёт в `_opening_hook` | +| 27 | 12.11.2026 | `VgoPair` помимо `mismatch` получил `mismatches` (по одному на год, где пара выбрана); `mismatch` — расхождение первого года | §5.5 выбирает пары заново каждый год, одним полем не выразить | потребители читают `mismatches`; `mismatch` — только для удобства | +| 28 | 12.11.2026 | Торговля: объём пары 5–20 % выручки продавца (с НДС), но суммарные закупки покупателя у группы ≤ 10 % его годовой выручки (пропорциональное сжатие); счёт в день 10, оплата 50–90 % в месяце счёта, остаток — в следующем; себестоимость продавца по ВГО-реализации не проводится, покупатель списывает товар (90.02 Кт 41) | без ограничения покупатель с 7 поставщиками уходил в минус по 51 (проверяется тестом); себестоимость продавца потребовала бы остатков 41 | прибыль продавца по ВГО завышена на себестоимость; объём при многих продавцах меньше 5 % | +| 29 | 12.11.2026 | Расхождение: процент малых — 1.05–3.95, крупных — 8.05–24.95 (первые `k//2` выбранных малые); проводка «в пути» — день 25, снятие — 31 января; у торговли — накладная продавца (Дт 62.01 Кт 90.01) или покупателя (Дт 41 Кт 60.01), у займа — транш по основному долгу (Дт 58.03 Кт 51 у займодавца либо Дт 51 Кт 66.03/67.03 у заёмщика; R-3, решение 32). `pct` считается от меньшей стороны сальдо на конец периода создания; в следующих периодах сальдо торговли меняется, сумма разницы остаётся | границы с запасом от 1/4/8/25 %, чтобы округление суммы не выводило процент из диапазона | пересчёт процента ядром по другому правилу даст иной pct | +| 30 | 12.11.2026 | Дивиденды: май каждого года книги, 3 % годовой выручки дочерней; доля головной по `head_share` (Дт 84 Кт 76.03 `An(cp=<код>)` ↔ Дт 76.03 Кт 91.01 item «Дивиденды»), остаток — миноритарию через `An(cp=<ИНН>)`; выплата в день 20 того же месяца | решение 11 требует нулевого сальдо 76.03; миноритарий получает свою долю | если нужны дивиденды из прибыли года — привязать к 84 | +| 31 | 12.11.2026 | Проценты по займам группы — 10 % годовых, начисляются и уплачиваются 28-го числа каждого месяца по субсчёту 76.09 обеих сторон | пары 76↔76 по правилу 6 ДП §4, остаток 76.09 нулевой | нет | +| 32 | 12.11.2026 | R-3 (рулинг контроллера по ревью Task 4): расхождение по займу — по основному долгу (правило 4 ДП §4), проценты 76.09 ↔ 76.09 начисляются симметрично и гасятся в месяце начисления, в расхождения не попадают; если у займодавца на конец периода денег меньше 2 × суммы транша, транш «в пути» проводит заёмщик. ВГО-выручка продавца за год ≤ 40 % его годовой выручки с НДС (`SELLER_CAP`, симметрично `BUYER_CAP`; пропорциональное сжатие) | расхождение в 76↔76 дало бы ядру правило 6 с меньшей стороной 0 (процент не определён, OQ-175); без потолка продавца ВГО-выручка могла превысить годовую выручку | при другом проценте ядра у займов оракул Task 5 расходится с генератором; потолок 40 % сжимает объём пар | +| 33 | 12.11.2026 | Свёртка ОСВ (`osv.rows`) идёт по индексу книги с накопленными по дням итогами (счёт, субсчёт, контрагент), а не через `Ledger.balance/turnover` на каждый период; результат совпадает с ними (тест) | на каждый период и счёт проходить по всем проводкам книги слишком долго | нет: расхождение ловится `test_rows_match_ledger` | +| 34 | 12.11.2026 | Строки ОСВ без сальдо и оборотов не пишутся; у счёта 80 без аналитики (головной) строка счёта без раскрытия; «Итого» — сумма строк счёта; колонка ИНН пишется только у контрагентов с ИНН | как в выгрузке 1С; ДП не требует нулевых строк | нет: парсер принимает, тест `-m parser` | +| 35 | 12.11.2026 | T3.8: закрывающие проводки различаются полем `Posting.kind` (`KIND_CLOSE_90` — 90.01..90.08 → 90.09, `KIND_CLOSE_99` — 90.09 и 91 → 99, `KIND_CLOSE_COST` — 26/44 → 90.08/90.07), memo остаётся подписью. В ОФР не входят `close90` и `close99`; закрытие затрат входит; обороты считаются как «нетто» Дт − Кт по счёту, а не только по одной стороне | разбор memo хрупок; закрытие на 90.09 удваивало бы обороты 90.01/90.02 | проводка без `kind` в закрывающем коде попадёт в ОФР: ловит `test_net_profit_equals_99_balance` | +| 36 | 12.11.2026 | Масштабированные колонки ББ (до начала книги): множитель 0.8–1.1 на строку из `rng_for(seed, "prev", код, вид, код компании, период, номер колонки)` (спека называла только код и вид — добавлены компания, период и колонка, чтобы колонки не совпадали между компаниями); баланс уравнивает строка 1370 (нераспределённая прибыль), а не ±1 подгонка | независимые множители ломают 1600 = 1700 на величину, не лечащуюся подгонкой ±1; прошлые колонки по спеке согласованности не требуют | 1370 прошлых колонок может выйти нетипичной (в т. ч. убыток) | +| 37 | 12.11.2026 | Подгонка округления ББ: ±1 тыс. строкам с наименьшей потерей точности, каждой строке не более одного раза; подогнанные строки записаны в `Form.plugged` (по колонкам) | «в одной строке» из §5.6 п. 3 не всегда хватает: при ~20 строках расхождение округлений достигает нескольких тыс. | строка подгонки может отличаться от книги на 1 тыс. (тест допускает ±1 только для `plugged`) | +| 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 new file mode 100644 index 0000000..fd8c11b --- /dev/null +++ b/synthgen/README.md @@ -0,0 +1,76 @@ +# synthgen — настраиваемый генератор синтетической ГСК + +Строит вымышленный набор для АРМ КА: состав группы (ЕЦП), ОСВ по счетам, ББ и ОФР по компаниям и периодам. Все числа +выведены из одной двойной записи на компанию (книга проводок), поэтому ББ, ОФР и ОСВ согласованы между собой. +Назначение — объём и демо (десятки компаний, до 150 внешних контрагентов в ОСВ); это **не оракул**: ожидаемых итогов +консолидации генератор не даёт. Проект: `docs/superpowers/specs/2026-11-12-synthgen-design.md`. + +## Запуск + +``` +uv run --with openpyxl==3.1.5 --with pyyaml==6.0.2 python -m synthgen \ + --config synthgen/examples/default.yaml --set group_size=12 --set prefix=DEMO2 --out /tmp/demo2 +``` + +Из корня репозитория fixtures. Без `--out` результат — `./out/`. Непустой каталог без `--force` — код 3; +`--force` очищает его содержимое целиком. Код 2 — ошибка настроек. Тесты: `python -m pytest synthgen/tests -q` +(`-m parser` — приёмка парсером, нужен `TP_WT`; `-m slow` — крупный прогон). + +## Настройки + +Любой ключ переопределяется `--set ключ=значение`; неизвестный ключ или значение вне диапазона — ошибка. + +| Ключ | Смысл | По умолчанию | Диапазон | +|---|---|---|---| +| `group_size` | число компаний в ГК (все входят в ГСК) | 5 | 2–40 | +| `prefix` | префикс ГК: в названиях и диапазон ИНН | `SYN` | 2–12 букв/цифр | +| `periods` | число отчётных периодов | 4 | 1–12 | +| `start` | первый период | `2025-Q1` | 2020-Q1 … 2030-Q4 | +| `osv_counterparties_mean` | среднее число внешних контрагентов в одной ОСВ | 60 | 0–150 | +| `no_inn_companies` | сколько компаний выгружают ОСВ без ИНН | 1 | 0–`group_size` | +| `vgo_mismatch_share` | доля пар ВГО с расхождением | 0.1 | 0–1 | +| `seed` | зерно случайности | 1 | целое | +| `out` | каталог результата | `./out/` | — | + +Одинаковые настройки и `seed` дают побайтно одинаковые файлы (путь результата в файлы не попадает). + +## Что получается + +``` +/ + README.md журнал прогона: настройки, состав группы, пары ВГО с расхождениями, контрагенты + config.yaml фактические настройки + directories/ecp-.xlsx выгрузка ЕЦП: состав ГК + osv/<КОД>/<период>/сч NN_….xlsx ОСВ по счетам 01, 58, 60, 62, 66, 67, 76, 80, 90 + statements/<КОД>/<период>/ ББ_….xlsx и ОФР_….xlsx (форма 66н, тыс. руб.) +``` + +`<КОД>` — `C01`…`Cnn`, головная — `C01`; периоды — нарастающим итогом с 1 января (`2025-Q1`, `2025-Q2`, `2025-Q3`, +`2025-FY`). Набор сам в git не коммитится (`out/` в `.gitignore`). + +## Как загрузить на стенд + +1. Импорт ЕЦП: `directories/ecp-.xlsx` (справочник групп и юрлиц). +2. Документы по компаниям: ОСВ, затем ББ и ОФР из `osv/<КОД>/…` и `statements/<КОД>/…`. Компании без ИНН в ОСВ и + пары ВГО с расхождениями перечислены в README набора. + +## Ограничения + +- Не оракул; только форма 66н (ФСБУ 4/2023 не покрыта). +- Среднее число контрагентов ограничено словарём (≤ 150): при потолке README набора пишет «среднее ограничено словарём». +- ИНН компаний группы берутся из диапазона по хешу префикса; два префикса могут столкнуться (малая вероятность). +- Выручка 90.01 в ОСВ без аналитики по контрагентам; счёт 80 головной без аналитики; подпись «За 3 месяца» в ОФР не + сверена с реальной выгрузкой первого квартала; внешние 58.03 и 76.05 только по Кт не проверялись на ядре. +- Побайтная воспроизводимость — в пределах одной платформы (float в весах и масштабах). +- Как ядро увидит расхождения (торговля и заём выше 5 % — нераспределённые строки `MULTIPLE_RULES`, до 5 % — автоматически), + какие особенности у данных и как прочитать пары — в README каждого прогона. +- `--force` чистит только каталог, который узнаёт как набор synthgen (`config.yaml` и README с заголовком набора), и + отказывает при `.git` в каталоге. + +## Как пересобрать словарь и шаблоны + +- Словарь организаций: `python -m synthgen.dictionary.build` → `synthgen/dictionary/companies.csv` (коммитится). +- Стиль ОСВ: `python synthgen/templates/build_osv_style.py` → `osv_style.json`; шаблоны ББ/ОФР: + `python synthgen/templates/build_forms_templates.py` → `bb_1c.xlsx`, `ofr_1c.xlsx`. Образцы — из этого же + репозитория (`osv/rb/2024-Q1`, `statements-xlsx/mezheninovskaya/2025-Q3`), путь можно передать аргументом; + реквизитов образца в шаблонах нет. diff --git a/synthgen/__init__.py b/synthgen/__init__.py new file mode 100644 index 0000000..f6c859f --- /dev/null +++ b/synthgen/__init__.py @@ -0,0 +1 @@ +"""Генератор синтетического набора АРМ КА (#38): ГК, ОСВ, ББ/ОФР, ВГО, ЕЦП — по спеке docs/superpowers/specs/2026-11-12-synthgen-design.md.""" diff --git a/synthgen/__main__.py b/synthgen/__main__.py new file mode 100644 index 0000000..2be5683 --- /dev/null +++ b/synthgen/__main__.py @@ -0,0 +1,50 @@ +"""Точка входа: `python -m synthgen --config synthgen/examples/default.yaml --set group_size=12 --out DIR`. + +Генерирует набор (спека §7) и печатает путь и сводку. Каталог результата по умолчанию — `./out/`; +непустой каталог без `--force` — код 3. +""" + +import argparse +import sys +from dataclasses import replace +from pathlib import Path + +from synthgen import generate +from synthgen.config import ConfigError, load_config + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="synthgen", description="Генератор синтетического набора АРМ КА.") + parser.add_argument("--config", type=Path, default=None, help="YAML с настройками") + parser.add_argument( + "--set", dest="overrides", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", + help="переопределение настройки (можно повторять)", + ) + parser.add_argument("--out", type=Path, default=None, help="каталог результата (по умолчанию ./out/)") + parser.add_argument("--force", action="store_true", help="очистить и перезаписать непустой каталог результата") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + cfg = load_config(args.config, args.overrides) + except ConfigError as exc: + print(f"ошибка конфигурации: {exc}", file=sys.stderr) + return 2 + if args.out is not None: + cfg = replace(cfg, out=args.out) + out = generate.default_out(cfg) + try: + manifest = generate.generate(cfg, out, force=args.force) + except FileExistsError as exc: + print(f"{exc}", file=sys.stderr) + return 3 + mismatches = sum(len(p.mismatches) for p in manifest.pairs) + print(f"Набор записан: {out}") + print(f"файлов: {len(manifest.files)}; пар ВГО: {len(manifest.pairs)}; расхождений: {mismatches}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synthgen/chart.py b/synthgen/chart.py new file mode 100644 index 0000000..31679a9 --- /dev/null +++ b/synthgen/chart.py @@ -0,0 +1,82 @@ +"""Источник: synthetic-group/gen/chart.py (#35). Используемые субсчета плана счетов (коды — из seed arm-core chart_of_accounts.csv).""" + +ACCOUNTS: dict[str, str] = { + "000": "Вспомогательный счет", + "01": "Основные средства", + "01.01": "Основные средства в организации", + "02": "Амортизация основных средств", + "03": "Доходные вложения в материальные ценности", + "08": "Вложения во внеоборотные активы", + "10": "Материалы", + "19": "НДС по приобретенным ценностям", + "20": "Основное производство", + "26": "Общехозяйственные расходы", + "41": "Товары", + "43": "Готовая продукция", + "44": "Расходы на продажу", + "50": "Касса", + "51": "Расчетные счета", + "52": "Валютные счета", + "55": "Специальные счета в банках", + "58.01": "Паи и акции", + "58.03": "Предоставленные займы", + "59": "Резервы под обесценение финансовых вложений", + "60.01": "Расчеты с поставщиками и подрядчиками", + "60.02": "Расчеты по авансам выданным", + "62.01": "Расчеты с покупателями и заказчиками", + "62.02": "Расчеты по авансам полученным", + "66.01": "Краткосрочные кредиты", + "66.02": "Проценты по краткосрочным кредитам", + "66.03": "Краткосрочные займы", + "66.04": "Проценты по краткосрочным займам", + "67.01": "Долгосрочные кредиты", + "67.02": "Проценты по долгосрочным кредитам", + "67.03": "Долгосрочные займы", + "67.04": "Проценты по долгосрочным займам", + "68": "Расчеты по налогам и сборам", + "68.04": "Налог на прибыль", + "69": "Расчеты по социальному страхованию и обеспечению", + "70": "Расчеты с персоналом по оплате труда", + "71": "Расчеты с подотчетными лицами", + "73": "Расчеты с персоналом по прочим операциям", + "75.02": "Расчеты по выплате доходов", + "76.03": "Расчеты по причитающимся дивидендам и другим доходам", + "76.05": "Расчеты с прочими поставщиками и подрядчиками", + "76.07.1": "Арендные обязательства", + "76.07.9": "НДС по арендным обязательствам", + "76.09": "Прочие расчеты с разными дебиторами и кредиторами", + "76.АВ": "НДС по авансам и предоплатам", + "80": "Уставный капитал", + "80.09": "Прочий капитал", + "82": "Резервный капитал", + "83": "Добавочный капитал", + "84": "Нераспределенная прибыль (непокрытый убыток)", + "86": "Целевое финансирование", + "90.01": "Выручка", + "90.02": "Себестоимость продаж", + "90.03": "Налог на добавленную стоимость", + "90.04": "Акцизы", + "90.05": "Экспортные пошлины", + # 90.06 в seed отсутствует + "90.07": "Расходы на продажу", + "90.08": "Управленческие расходы", + "90.09": "Прибыль / убыток от продаж", + "91.01": "Прочие доходы", + "91.02": "Прочие расходы", + "91.09": "Сальдо прочих доходов и расходов", + "96": "Резервы предстоящих расходов", + "97": "Расходы будущих периодов", + "99": "Прибыли и убытки", + "001": "Арендованные основные средства", + "008": "Обеспечения обязательств и платежей полученные", + "009": "Обеспечения обязательств и платежей выданные", + "011": "Основные средства, сданные в аренду", +} + + +def is_off_balance(code: str) -> bool: + return code.isdigit() and len(code) == 3 and "001" <= code <= "011" + + +def parent(code: str) -> str: + return code.rsplit(".", 1)[0] if "." in code else code diff --git a/synthgen/config.py b/synthgen/config.py new file mode 100644 index 0000000..6d79c53 --- /dev/null +++ b/synthgen/config.py @@ -0,0 +1,151 @@ +"""Настройки генератора (спека §3): YAML-файл + переопределения `--set ключ=значение`. + +Неизвестный ключ или значение вне диапазона — ConfigError до начала генерации. +""" + +import re +from dataclasses import dataclass, fields +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +import yaml + + +class ConfigError(ValueError): + """Ошибка настроек: неизвестный ключ, неверный тип или значение вне диапазона.""" + + +@dataclass(frozen=True) +class Config: + 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 + + +_PREFIX_RE = re.compile(r"[0-9A-Za-zА-Яа-яЁё]{2,12}") +_START_RE = re.compile(r"(\d{4})-Q([1-4])") + + +def _as_int(name: str, value: Any) -> int: + if isinstance(value, bool): + raise ConfigError(f"{name}: ожидается целое число, получено {value!r}") + if isinstance(value, int): + return value + if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()): + return int(value.strip()) + raise ConfigError(f"{name}: ожидается целое число, получено {value!r}") + + +def _as_str(name: str, value: Any) -> str: + if isinstance(value, bool): + raise ConfigError(f"{name}: ожидается строка, получено {value!r}") + if isinstance(value, (str, int)): + return str(value) + raise ConfigError(f"{name}: ожидается строка, получено {value!r}") + + +def _as_decimal(name: str, value: Any) -> Decimal: + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise ConfigError(f"{name}: ожидается число, получено {value!r}") + try: + number = Decimal(str(value).strip()) + except InvalidOperation as exc: + raise ConfigError(f"{name}: ожидается число, получено {value!r}") from exc + if not number.is_finite(): + raise ConfigError(f"{name}: ожидается конечное число, получено {value!r}") + return number + + +def _as_path(name: str, value: Any) -> Path | None: + if value is None or value == "": + return None + if isinstance(value, str): + return Path(value) + raise ConfigError(f"{name}: ожидается путь, получено {value!r}") + + +_COERCERS = { + "group_size": _as_int, + "prefix": _as_str, + "periods": _as_int, + "start": _as_str, + "osv_counterparties_mean": _as_int, + "no_inn_companies": _as_int, + "vgo_mismatch_share": _as_decimal, + "seed": _as_int, + "out": _as_path, +} + + +def _validate(cfg: Config) -> None: + if not 2 <= cfg.group_size <= 40: + raise ConfigError(f"group_size: вне диапазона 2–40, получено {cfg.group_size}") + if not _PREFIX_RE.fullmatch(cfg.prefix): + raise ConfigError(f"prefix: 2–12 букв/цифр без пробелов, получено {cfg.prefix!r}") + if not 1 <= cfg.periods <= 12: + raise ConfigError(f"periods: вне диапазона 1–12, получено {cfg.periods}") + match = _START_RE.fullmatch(cfg.start) + if match is None: + raise ConfigError(f"start: ожидается ГГГГ-Qn, получено {cfg.start!r}") + start_key = (int(match.group(1)), int(match.group(2))) + if not (2020, 1) <= start_key <= (2030, 4): + raise ConfigError(f"start: вне диапазона 2020-Q1 … 2030-Q4, получено {cfg.start!r}") + if not 0 <= cfg.osv_counterparties_mean <= 150: + raise ConfigError(f"osv_counterparties_mean: вне диапазона 0–150, получено {cfg.osv_counterparties_mean}") + if not 0 <= cfg.no_inn_companies <= cfg.group_size: + raise ConfigError( + f"no_inn_companies: вне диапазона 0–group_size ({cfg.group_size}), получено {cfg.no_inn_companies}" + ) + if not Decimal(0) <= cfg.vgo_mismatch_share <= Decimal(1): + raise ConfigError(f"vgo_mismatch_share: вне диапазона 0–1, получено {cfg.vgo_mismatch_share}") + + +def load_config(path: Path | None, overrides: list[str]) -> Config: + """Читает YAML (если задан) и накладывает переопределения `ключ=значение`.""" + known = {f.name for f in fields(Config)} + data: dict[str, Any] = {} + if path is not None: + try: + loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + except OSError as exc: + raise ConfigError(f"не удалось прочитать {path}: {exc}") from exc + except yaml.YAMLError as exc: + raise ConfigError(f"некорректный YAML в {path}: {exc}") from exc + if loaded is None: + loaded = {} + if not isinstance(loaded, dict): + raise ConfigError(f"{path}: верхний уровень должен быть отображением ключ: значение") + data.update(loaded) + for item in overrides: + if "=" not in item: + raise ConfigError(f"--set: ожидается ключ=значение, получено {item!r}") + key, value = item.split("=", 1) + data[key.strip()] = value + kwargs: dict[str, Any] = {} + for key, value in data.items(): + if key not in known: + raise ConfigError(f"неизвестный ключ: {key!r}") + kwargs[key] = _COERCERS[key](key, value) + cfg = Config(**kwargs) + _validate(cfg) + return cfg + + +def dump_config(cfg: Config) -> str: + """YAML с ключами в порядке полей Config; значение `out` None → null.""" + data: dict[str, Any] = {} + for f in fields(Config): + value = getattr(cfg, f.name) + if isinstance(value, Path): + value = str(value) + elif isinstance(value, Decimal): + value = float(value) + data[f.name] = value + return yaml.safe_dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False) diff --git a/synthgen/counterparties.py b/synthgen/counterparties.py new file mode 100644 index 0000000..bdcd562 --- /dev/null +++ b/synthgen/counterparties.py @@ -0,0 +1,79 @@ +"""Внешние контрагенты в ОСВ (спека §5.3): сколько и кто, для каждой пары (компания, счёт). + +Число `n` — отрицательное биномиальное (гамма-пуассоновская смесь с дисперсией ≈ 2 × матожидание), матожидание +`mean × w(счёт) / среднее_w`, так что среднее `n` по файлам счетов 58/60/62/66/67/76 ≈ `osv_counterparties_mean`. +Потолки: 58/60/62/76 — словарь без банков и компаний группы, 66/67 — банки. Недобор из-за потолка перераспределяется на +остальные файлы того же счёта (решение 9); если потолок всё же срезал выборку — `capped=True`. +""" + +import math +import random +from dataclasses import dataclass, field + +from synthgen.config import Config +from synthgen.dictionary import Org +from synthgen.group import Group +from synthgen.rng import rng_for + +OSV_ACCOUNTS = ("01", "58", "60", "62", "66", "67", "76", "80", "90") +WEIGHTS = {"60": 1.6, "62": 1.6, "76": 1.0, "66": 0.1, "67": 0.05, "58": 0.1} +BANK_ACCOUNTS = ("66", "67") +SERVICE_ACCOUNTS = ("60", "62", "76") # при mean = 0 в этих файлах один служебный внешний контрагент + + +@dataclass(frozen=True) +class CounterpartyPlan: + by_file: dict[tuple[str, str], list[Org]] = field(default_factory=dict) # (код компании, корень счёта) → контрагенты + capped: bool = False # потолок словаря срезал выборку хотя бы в одном файле + + +def _poisson(rng: random.Random, lam: float) -> int: + if lam <= 0: + return 0 + if lam > 30: # нормальное приближение: точное для нашей задачи, а цикл Кнута на больших λ медленный + return max(0, round(rng.gauss(lam, math.sqrt(lam)))) + limit, k, p = math.exp(-lam), 0, 1.0 + while True: + p *= rng.random() + if p <= limit: + return k + k += 1 + + +def _draw_n(rng: random.Random, mu: float) -> int: + """Отрицательное биномиальное с матожиданием `mu` и дисперсией 2·mu: Poisson(Gamma(mu, 1)).""" + if mu <= 0: + return 0 + return _poisson(rng, rng.gammavariate(mu, 1.0)) + + +def plan(cfg: Config, group: Group) -> CounterpartyPlan: + codes = [m.code for m in group.members] + avg_w = sum(WEIGHTS.values()) / len(WEIGHTS) + capped = False + by_file: dict[tuple[str, str], list[Org]] = {} + for acct, w in WEIGHTS.items(): + pool = group.banks if acct in BANK_ACCOUNTS else group.externals + cap = len(pool) + mu = cfg.osv_counterparties_mean * w / avg_w + want = [_draw_n(rng_for(cfg.seed, "cp-count", code, acct), mu) for code in codes] + 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: + capped = True + room = [i for i, n in enumerate(got) if n < cap] + while shortfall > 0 and room: # круговое доведение недобора до потолка файлов, где есть место + nxt = [] + for i in room: + if shortfall == 0: + break + got[i] += 1 + shortfall -= 1 + if got[i] < cap: + nxt.append(i) + room = nxt + for code, n in zip(codes, got): + by_file[(code, acct)] = rng_for(cfg.seed, "cp-set", code, acct).sample(pool, n) if n else [] + return CounterpartyPlan(by_file=by_file, capped=capped) diff --git a/synthgen/dictionary/__init__.py b/synthgen/dictionary/__init__.py new file mode 100644 index 0000000..10e0cab --- /dev/null +++ b/synthgen/dictionary/__init__.py @@ -0,0 +1,34 @@ +"""Словарь вымышленных организаций (спека §4.1): чтение закоммиченного `companies.csv`. + +Это пакет (а не модуль `dictionary.py`), чтобы работал `python -m synthgen.dictionary.build` (решение 17). +""" + +import csv +from dataclasses import dataclass +from pathlib import Path + +CSV_PATH = Path(__file__).with_name("companies.csv") +HEADER = ["name", "form", "inn", "kpp", "kind"] + + +@dataclass(frozen=True) +class Org: + """Организация словаря. `name` — как её пишет 1С: `Северная Нива ООО`, `Аверина Е.И. ИП`.""" + + name: str + form: str # ООО | АО | ПАО | ИП + inn: str + kpp: str # у ИП пусто + kind: str # отрасль или «банк» + + @property + def root(self) -> str: + """Имя без формы: `Северная Нива`.""" + return self.name.rsplit(" ", 1)[0] + + +def load(path: Path | None = None) -> list[Org]: + """Организации словаря в порядке файла.""" + with (path or CSV_PATH).open(encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh, delimiter=";") + return [Org(r["name"], r["form"], r["inn"], r["kpp"], r["kind"]) for r in reader] diff --git a/synthgen/dictionary/build.py b/synthgen/dictionary/build.py new file mode 100644 index 0000000..989dcc9 --- /dev/null +++ b/synthgen/dictionary/build.py @@ -0,0 +1,109 @@ +"""Сборка `companies.csv` (спека §4.1): 400 вымышленных организаций без случайности. + +Запуск из корня репозитория: `python -m synthgen.dictionary.build` — перезаписывает `companies.csv`. +Имена — перебор «прилагательное × существительное» по отраслям с фиксированным шагом, ИНН — подряд от 0100001. +""" + +import csv +from pathlib import Path + +from synthgen import inn +from synthgen.dictionary import CSV_PATH, HEADER, Org + +FIRST_SEQ = 100001 # ИНН-номер 0100001 +PER_SECTOR = 55 +IP_COUNT = 40 +BANK_COUNT = 30 + +# (основа, окончания для m/f/n) +_ADJ = ["Лазурн", "Янтарн", "Изумрудн", "Рубинов", "Серебрян", "Заревн", "Лунн", "Северн"] +_ENDINGS = {"m": "ый", "f": "ая", "n": "ое"} + +# отрасль → существительные (слово, род) +_SECTORS: dict[str, list[tuple[str, str]]] = { + "агро": [("Нива", "f"), ("Колос", "m"), ("Урожай", "m"), ("Пашня", "f"), ("Зерно", "n"), + ("Луг", "m"), ("Сад", "m"), ("Борозда", "f"), ("Поле", "n"), ("Амбар", "m")], + "торговля": [("Рынок", "m"), ("Склад", "m"), ("Лавка", "f"), ("Ярмарка", "f"), ("Базар", "m"), + ("Торг", "m"), ("Магазин", "m"), ("Прилавок", "m"), ("Пассаж", "m"), ("Товар", "m")], + "логистика": [("Путь", "m"), ("Маршрут", "m"), ("Фургон", "m"), ("Трасса", "f"), ("Обоз", "m"), + ("Тягач", "m"), ("Рейс", "m"), ("Перевал", "m"), ("Караван", "m"), ("Эстакада", "f")], + "производство": [("Завод", "m"), ("Цех", "m"), ("Станок", "m"), ("Мельница", "f"), ("Пекарня", "f"), + ("Кузница", "f"), ("Фабрика", "f"), ("Горн", "m"), ("Верфь", "f"), ("Мануфактура", "f")], + "услуги": [("Сервис", "m"), ("Бюро", "n"), ("Центр", "m"), ("Консалт", "m"), ("Профи", "m"), + ("Партнёр", "m"), ("Совет", "m"), ("Контора", "f"), ("Мастерская", "f"), ("Приём", "m")], + "строительство": [("Фундамент", "m"), ("Кирпич", "m"), ("Монолит", "m"), ("Каркас", "m"), ("Проект", "m"), + ("Стройка", "f"), ("Бастион", "m"), ("Зодчий", "m"), ("Арка", "f"), ("Мост", "m")], +} + +_BANK_WORDS = ["Банк", "Кредитный Банк", "Деловой Банк", "Земельный Банк"] + +_SURNAMES = [ + "Авдонина", "Бражников", "Ветрова", "Голубев", "Дроздова", "Ермаков", "Жарова", "Зуев", "Игнатова", "Копылов", + "Лаврова", "Мартынов", "Норина", "Овчаров", "Пименова", "Рябов", "Сорокина", "Тарасов", "Устинова", "Фролов", + "Хромова", "Цветаев", "Чернова", "Шаров", "Щукина", "Юдин", "Яковлева", "Баранов", "Волкова", "Гусев", + "Денисова", "Елисеев", "Зайцева", "Исаев", "Калинина", "Леонов", "Маслова", "Назаров", "Орлова", "Панов", +] +_INITIALS = ["Е.И.", "А.П.", "Н.С.", "В.Д.", "О.А.", "М.В.", "И.Н.", "С.К.", "Т.Р.", "Д.Л."] +_IP_KINDS = ["услуги", "торговля", "логистика", "агро", "строительство", "производство"] + + +def _kpp(seq: int) -> str: + """КПП: `00` + двузначный номер + `01001` (как в #35).""" + return f"00{seq % 100:02d}01001" + + +def _legal_form(idx: int) -> str: + if idx % 17 == 0: + return "ПАО" + return "АО" if idx % 5 == 0 else "ООО" + + +def build_records() -> list[Org]: + """400 записей: 330 юрлиц по отраслям (с шагом 3 по сетке 80 пар), 40 ИП, 30 банков.""" + orgs: list[Org] = [] + seq = FIRST_SEQ + + def add(name: str, form: str, kind: str) -> None: + nonlocal seq + if form == "ИП": + orgs.append(Org(name, form, inn.make_inn12(seq), "", kind)) + else: + orgs.append(Org(name, form, inn.make_inn10(seq), _kpp(seq), kind)) + seq += 1 + + legal_idx = 0 + for j in range(PER_SECTOR): + pair = (j * 3) % (len(_ADJ) * 10) # gcd(3, 80) = 1 — пары не повторяются + adj_stem = _ADJ[pair % len(_ADJ)] + for kind, nouns in _SECTORS.items(): + noun, gender = nouns[pair // len(_ADJ)] + legal_idx += 1 + form = _legal_form(legal_idx) + add(f"{adj_stem}{_ENDINGS[gender]} {noun} {form}", form, kind) + for j in range(IP_COUNT): + add(f"{_SURNAMES[j]} {_INITIALS[j % len(_INITIALS)]} ИП", "ИП", _IP_KINDS[j % len(_IP_KINDS)]) + for j in range(BANK_COUNT): + stem = _ADJ[j % len(_ADJ)] + words = _BANK_WORDS[(j // len(_ADJ)) % len(_BANK_WORDS)] + form = "ПАО" if j % 2 else "АО" + add(f"{stem}ый {words} {form}", form, "банк") + return orgs + + +def write_csv(path: Path, orgs: list[Org] | None = None) -> None: + rows = orgs if orgs is not None else build_records() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.writer(fh, delimiter=";", lineterminator="\n") + writer.writerow(HEADER) + for o in rows: + writer.writerow([o.name, o.form, o.inn, o.kpp, o.kind]) + + +def main() -> None: + write_csv(CSV_PATH) + print(f"записано {CSV_PATH}") + + +if __name__ == "__main__": + main() diff --git a/synthgen/dictionary/companies.csv b/synthgen/dictionary/companies.csv new file mode 100644 index 0000000..84c9fc4 --- /dev/null +++ b/synthgen/dictionary/companies.csv @@ -0,0 +1,401 @@ +name;form;inn;kpp;kind +Лазурная Нива ООО;ООО;0001000010;000101001;агро +Лазурный Рынок ООО;ООО;0001000028;000201001;торговля +Лазурный Путь ООО;ООО;0001000035;000301001;логистика +Лазурный Завод ООО;ООО;0001000042;000401001;производство +Лазурный Сервис АО;АО;0001000050;000501001;услуги +Лазурный Фундамент ООО;ООО;0001000067;000601001;строительство +Рубиновая Нива ООО;ООО;0001000074;000701001;агро +Рубиновый Рынок ООО;ООО;0001000081;000801001;торговля +Рубиновый Путь ООО;ООО;0001000099;000901001;логистика +Рубиновый Завод АО;АО;0001000109;001001001;производство +Рубиновый Сервис ООО;ООО;0001000116;001101001;услуги +Рубиновый Фундамент ООО;ООО;0001000123;001201001;строительство +Лунная Нива ООО;ООО;0001000130;001301001;агро +Лунный Рынок ООО;ООО;0001000148;001401001;торговля +Лунный Путь АО;АО;0001000155;001501001;логистика +Лунный Завод ООО;ООО;0001000162;001601001;производство +Лунный Сервис ПАО;ПАО;0001000170;001701001;услуги +Лунный Фундамент ООО;ООО;0001000187;001801001;строительство +Янтарный Колос ООО;ООО;0001000194;001901001;агро +Янтарный Склад АО;АО;0001000204;002001001;торговля +Янтарный Маршрут ООО;ООО;0001000211;002101001;логистика +Янтарный Цех ООО;ООО;0001000229;002201001;производство +Янтарное Бюро ООО;ООО;0001000236;002301001;услуги +Янтарный Кирпич ООО;ООО;0001000243;002401001;строительство +Серебряный Колос АО;АО;0001000250;002501001;агро +Серебряный Склад ООО;ООО;0001000268;002601001;торговля +Серебряный Маршрут ООО;ООО;0001000275;002701001;логистика +Серебряный Цех ООО;ООО;0001000282;002801001;производство +Серебряное Бюро ООО;ООО;0001000290;002901001;услуги +Серебряный Кирпич АО;АО;0001000300;003001001;строительство +Северный Колос ООО;ООО;0001000317;003101001;агро +Северный Склад ООО;ООО;0001000324;003201001;торговля +Северный Маршрут ООО;ООО;0001000331;003301001;логистика +Северный Цех ПАО;ПАО;0001000349;003401001;производство +Северное Бюро АО;АО;0001000356;003501001;услуги +Северный Кирпич ООО;ООО;0001000363;003601001;строительство +Изумрудный Урожай ООО;ООО;0001000370;003701001;агро +Изумрудная Лавка ООО;ООО;0001000388;003801001;торговля +Изумрудный Фургон ООО;ООО;0001000395;003901001;логистика +Изумрудный Станок АО;АО;0001000405;004001001;производство +Изумрудный Центр ООО;ООО;0001000412;004101001;услуги +Изумрудный Монолит ООО;ООО;0001000420;004201001;строительство +Заревный Урожай ООО;ООО;0001000437;004301001;агро +Заревная Лавка ООО;ООО;0001000444;004401001;торговля +Заревный Фургон АО;АО;0001000451;004501001;логистика +Заревный Станок ООО;ООО;0001000469;004601001;производство +Заревный Центр ООО;ООО;0001000476;004701001;услуги +Заревный Монолит ООО;ООО;0001000483;004801001;строительство +Лазурная Пашня ООО;ООО;0001000490;004901001;агро +Лазурная Ярмарка АО;АО;0001000500;005001001;торговля +Лазурная Трасса ПАО;ПАО;0001000518;005101001;логистика +Лазурная Мельница ООО;ООО;0001000525;005201001;производство +Лазурный Консалт ООО;ООО;0001000532;005301001;услуги +Лазурный Каркас ООО;ООО;0001000540;005401001;строительство +Рубиновая Пашня АО;АО;0001000557;005501001;агро +Рубиновая Ярмарка ООО;ООО;0001000564;005601001;торговля +Рубиновая Трасса ООО;ООО;0001000571;005701001;логистика +Рубиновая Мельница ООО;ООО;0001000589;005801001;производство +Рубиновый Консалт ООО;ООО;0001000596;005901001;услуги +Рубиновый Каркас АО;АО;0001000606;006001001;строительство +Лунная Пашня ООО;ООО;0001000613;006101001;агро +Лунная Ярмарка ООО;ООО;0001000620;006201001;торговля +Лунная Трасса ООО;ООО;0001000638;006301001;логистика +Лунная Мельница ООО;ООО;0001000645;006401001;производство +Лунный Консалт АО;АО;0001000652;006501001;услуги +Лунный Каркас ООО;ООО;0001000660;006601001;строительство +Янтарное Зерно ООО;ООО;0001000677;006701001;агро +Янтарный Базар ПАО;ПАО;0001000684;006801001;торговля +Янтарный Обоз ООО;ООО;0001000691;006901001;логистика +Янтарная Пекарня АО;АО;0001000701;007001001;производство +Янтарный Профи ООО;ООО;0001000719;007101001;услуги +Янтарный Проект ООО;ООО;0001000726;007201001;строительство +Серебряное Зерно ООО;ООО;0001000733;007301001;агро +Серебряный Базар ООО;ООО;0001000740;007401001;торговля +Серебряный Обоз АО;АО;0001000758;007501001;логистика +Серебряная Пекарня ООО;ООО;0001000765;007601001;производство +Серебряный Профи ООО;ООО;0001000772;007701001;услуги +Серебряный Проект ООО;ООО;0001000780;007801001;строительство +Северное Зерно ООО;ООО;0001000797;007901001;агро +Северный Базар АО;АО;0001000807;008001001;торговля +Северный Обоз ООО;ООО;0001000814;008101001;логистика +Северная Пекарня ООО;ООО;0001000821;008201001;производство +Северный Профи ООО;ООО;0001000839;008301001;услуги +Северный Проект ООО;ООО;0001000846;008401001;строительство +Изумрудный Луг ПАО;ПАО;0001000853;008501001;агро +Изумрудный Торг ООО;ООО;0001000860;008601001;торговля +Изумрудный Тягач ООО;ООО;0001000878;008701001;логистика +Изумрудная Кузница ООО;ООО;0001000885;008801001;производство +Изумрудный Партнёр ООО;ООО;0001000892;008901001;услуги +Изумрудная Стройка АО;АО;0001000902;009001001;строительство +Заревный Луг ООО;ООО;0001000910;009101001;агро +Заревный Торг ООО;ООО;0001000927;009201001;торговля +Заревный Тягач ООО;ООО;0001000934;009301001;логистика +Заревная Кузница ООО;ООО;0001000941;009401001;производство +Заревный Партнёр АО;АО;0001000959;009501001;услуги +Заревная Стройка ООО;ООО;0001000966;009601001;строительство +Лазурный Сад ООО;ООО;0001000973;009701001;агро +Лазурный Магазин ООО;ООО;0001000980;009801001;торговля +Лазурный Рейс ООО;ООО;0001000998;009901001;логистика +Лазурная Фабрика АО;АО;0001001007;000001001;производство +Лазурный Совет ООО;ООО;0001001014;000101001;услуги +Лазурный Бастион ПАО;ПАО;0001001021;000201001;строительство +Рубиновый Сад ООО;ООО;0001001039;000301001;агро +Рубиновый Магазин ООО;ООО;0001001046;000401001;торговля +Рубиновый Рейс АО;АО;0001001053;000501001;логистика +Рубиновая Фабрика ООО;ООО;0001001060;000601001;производство +Рубиновый Совет ООО;ООО;0001001078;000701001;услуги +Рубиновый Бастион ООО;ООО;0001001085;000801001;строительство +Лунный Сад ООО;ООО;0001001092;000901001;агро +Лунный Магазин АО;АО;0001001102;001001001;торговля +Лунный Рейс ООО;ООО;0001001110;001101001;логистика +Лунная Фабрика ООО;ООО;0001001127;001201001;производство +Лунный Совет ООО;ООО;0001001134;001301001;услуги +Лунный Бастион ООО;ООО;0001001141;001401001;строительство +Янтарная Борозда АО;АО;0001001159;001501001;агро +Янтарный Прилавок ООО;ООО;0001001166;001601001;торговля +Янтарный Перевал ООО;ООО;0001001173;001701001;логистика +Янтарный Горн ООО;ООО;0001001180;001801001;производство +Янтарная Контора ПАО;ПАО;0001001198;001901001;услуги +Янтарный Зодчий АО;АО;0001001208;002001001;строительство +Серебряная Борозда ООО;ООО;0001001215;002101001;агро +Серебряный Прилавок ООО;ООО;0001001222;002201001;торговля +Серебряный Перевал ООО;ООО;0001001230;002301001;логистика +Серебряный Горн ООО;ООО;0001001247;002401001;производство +Серебряная Контора АО;АО;0001001254;002501001;услуги +Серебряный Зодчий ООО;ООО;0001001261;002601001;строительство +Северная Борозда ООО;ООО;0001001279;002701001;агро +Северный Прилавок ООО;ООО;0001001286;002801001;торговля +Северный Перевал ООО;ООО;0001001293;002901001;логистика +Северный Горн АО;АО;0001001303;003001001;производство +Северная Контора ООО;ООО;0001001310;003101001;услуги +Северный Зодчий ООО;ООО;0001001328;003201001;строительство +Изумрудное Поле ООО;ООО;0001001335;003301001;агро +Изумрудный Пассаж ООО;ООО;0001001342;003401001;торговля +Изумрудный Караван АО;АО;0001001350;003501001;логистика +Изумрудная Верфь ПАО;ПАО;0001001367;003601001;производство +Изумрудная Мастерская ООО;ООО;0001001374;003701001;услуги +Изумрудная Арка ООО;ООО;0001001381;003801001;строительство +Заревное Поле ООО;ООО;0001001399;003901001;агро +Заревный Пассаж АО;АО;0001001409;004001001;торговля +Заревный Караван ООО;ООО;0001001416;004101001;логистика +Заревная Верфь ООО;ООО;0001001423;004201001;производство +Заревная Мастерская ООО;ООО;0001001430;004301001;услуги +Заревная Арка ООО;ООО;0001001448;004401001;строительство +Лазурный Амбар АО;АО;0001001455;004501001;агро +Лазурный Товар ООО;ООО;0001001462;004601001;торговля +Лазурная Эстакада ООО;ООО;0001001470;004701001;логистика +Лазурная Мануфактура ООО;ООО;0001001487;004801001;производство +Лазурный Приём ООО;ООО;0001001494;004901001;услуги +Лазурный Мост АО;АО;0001001504;005001001;строительство +Рубиновый Амбар ООО;ООО;0001001511;005101001;агро +Рубиновый Товар ООО;ООО;0001001529;005201001;торговля +Рубиновая Эстакада ПАО;ПАО;0001001536;005301001;логистика +Рубиновая Мануфактура ООО;ООО;0001001543;005401001;производство +Рубиновый Приём АО;АО;0001001550;005501001;услуги +Рубиновый Мост ООО;ООО;0001001568;005601001;строительство +Лунный Амбар ООО;ООО;0001001575;005701001;агро +Лунный Товар ООО;ООО;0001001582;005801001;торговля +Лунная Эстакада ООО;ООО;0001001590;005901001;логистика +Лунная Мануфактура АО;АО;0001001600;006001001;производство +Лунный Приём ООО;ООО;0001001617;006101001;услуги +Лунный Мост ООО;ООО;0001001624;006201001;строительство +Янтарная Нива ООО;ООО;0001001631;006301001;агро +Янтарный Рынок ООО;ООО;0001001649;006401001;торговля +Янтарный Путь АО;АО;0001001656;006501001;логистика +Янтарный Завод ООО;ООО;0001001663;006601001;производство +Янтарный Сервис ООО;ООО;0001001670;006701001;услуги +Янтарный Фундамент ООО;ООО;0001001688;006801001;строительство +Серебряная Нива ООО;ООО;0001001695;006901001;агро +Серебряный Рынок ПАО;ПАО;0001001705;007001001;торговля +Серебряный Путь ООО;ООО;0001001712;007101001;логистика +Серебряный Завод ООО;ООО;0001001720;007201001;производство +Серебряный Сервис ООО;ООО;0001001737;007301001;услуги +Серебряный Фундамент ООО;ООО;0001001744;007401001;строительство +Северная Нива АО;АО;0001001751;007501001;агро +Северный Рынок ООО;ООО;0001001769;007601001;торговля +Северный Путь ООО;ООО;0001001776;007701001;логистика +Северный Завод ООО;ООО;0001001783;007801001;производство +Северный Сервис ООО;ООО;0001001790;007901001;услуги +Северный Фундамент АО;АО;0001001800;008001001;строительство +Изумрудный Колос ООО;ООО;0001001818;008101001;агро +Изумрудный Склад ООО;ООО;0001001825;008201001;торговля +Изумрудный Маршрут ООО;ООО;0001001832;008301001;логистика +Изумрудный Цех ООО;ООО;0001001840;008401001;производство +Изумрудное Бюро АО;АО;0001001857;008501001;услуги +Изумрудный Кирпич ООО;ООО;0001001864;008601001;строительство +Заревный Колос ПАО;ПАО;0001001871;008701001;агро +Заревный Склад ООО;ООО;0001001889;008801001;торговля +Заревный Маршрут ООО;ООО;0001001896;008901001;логистика +Заревный Цех АО;АО;0001001906;009001001;производство +Заревное Бюро ООО;ООО;0001001913;009101001;услуги +Заревный Кирпич ООО;ООО;0001001920;009201001;строительство +Лазурный Урожай ООО;ООО;0001001938;009301001;агро +Лазурная Лавка ООО;ООО;0001001945;009401001;торговля +Лазурный Фургон АО;АО;0001001952;009501001;логистика +Лазурный Станок ООО;ООО;0001001960;009601001;производство +Лазурный Центр ООО;ООО;0001001977;009701001;услуги +Лазурный Монолит ООО;ООО;0001001984;009801001;строительство +Рубиновый Урожай ООО;ООО;0001001991;009901001;агро +Рубиновая Лавка АО;АО;0001002000;000001001;торговля +Рубиновый Фургон ООО;ООО;0001002018;000101001;логистика +Рубиновый Станок ООО;ООО;0001002025;000201001;производство +Рубиновый Центр ООО;ООО;0001002032;000301001;услуги +Рубиновый Монолит ПАО;ПАО;0001002040;000401001;строительство +Лунный Урожай АО;АО;0001002057;000501001;агро +Лунная Лавка ООО;ООО;0001002064;000601001;торговля +Лунный Фургон ООО;ООО;0001002071;000701001;логистика +Лунный Станок ООО;ООО;0001002089;000801001;производство +Лунный Центр ООО;ООО;0001002096;000901001;услуги +Лунный Монолит АО;АО;0001002106;001001001;строительство +Янтарная Пашня ООО;ООО;0001002113;001101001;агро +Янтарная Ярмарка ООО;ООО;0001002120;001201001;торговля +Янтарная Трасса ООО;ООО;0001002138;001301001;логистика +Янтарная Мельница ООО;ООО;0001002145;001401001;производство +Янтарный Консалт АО;АО;0001002152;001501001;услуги +Янтарный Каркас ООО;ООО;0001002160;001601001;строительство +Серебряная Пашня ООО;ООО;0001002177;001701001;агро +Серебряная Ярмарка ООО;ООО;0001002184;001801001;торговля +Серебряная Трасса ООО;ООО;0001002191;001901001;логистика +Серебряная Мельница АО;АО;0001002201;002001001;производство +Серебряный Консалт ПАО;ПАО;0001002219;002101001;услуги +Серебряный Каркас ООО;ООО;0001002226;002201001;строительство +Северная Пашня ООО;ООО;0001002233;002301001;агро +Северная Ярмарка ООО;ООО;0001002240;002401001;торговля +Северная Трасса АО;АО;0001002258;002501001;логистика +Северная Мельница ООО;ООО;0001002265;002601001;производство +Северный Консалт ООО;ООО;0001002272;002701001;услуги +Северный Каркас ООО;ООО;0001002280;002801001;строительство +Изумрудное Зерно ООО;ООО;0001002297;002901001;агро +Изумрудный Базар АО;АО;0001002307;003001001;торговля +Изумрудный Обоз ООО;ООО;0001002314;003101001;логистика +Изумрудная Пекарня ООО;ООО;0001002321;003201001;производство +Изумрудный Профи ООО;ООО;0001002339;003301001;услуги +Изумрудный Проект ООО;ООО;0001002346;003401001;строительство +Заревное Зерно АО;АО;0001002353;003501001;агро +Заревный Базар ООО;ООО;0001002360;003601001;торговля +Заревный Обоз ООО;ООО;0001002378;003701001;логистика +Заревная Пекарня ПАО;ПАО;0001002385;003801001;производство +Заревный Профи ООО;ООО;0001002392;003901001;услуги +Заревный Проект АО;АО;0001002402;004001001;строительство +Лазурный Луг ООО;ООО;0001002410;004101001;агро +Лазурный Торг ООО;ООО;0001002427;004201001;торговля +Лазурный Тягач ООО;ООО;0001002434;004301001;логистика +Лазурная Кузница ООО;ООО;0001002441;004401001;производство +Лазурный Партнёр АО;АО;0001002459;004501001;услуги +Лазурная Стройка ООО;ООО;0001002466;004601001;строительство +Рубиновый Луг ООО;ООО;0001002473;004701001;агро +Рубиновый Торг ООО;ООО;0001002480;004801001;торговля +Рубиновый Тягач ООО;ООО;0001002498;004901001;логистика +Рубиновая Кузница АО;АО;0001002508;005001001;производство +Рубиновый Партнёр ООО;ООО;0001002515;005101001;услуги +Рубиновая Стройка ООО;ООО;0001002522;005201001;строительство +Лунный Луг ООО;ООО;0001002530;005301001;агро +Лунный Торг ООО;ООО;0001002547;005401001;торговля +Лунный Тягач ПАО;ПАО;0001002554;005501001;логистика +Лунная Кузница ООО;ООО;0001002561;005601001;производство +Лунный Партнёр ООО;ООО;0001002579;005701001;услуги +Лунная Стройка ООО;ООО;0001002586;005801001;строительство +Янтарный Сад ООО;ООО;0001002593;005901001;агро +Янтарный Магазин АО;АО;0001002603;006001001;торговля +Янтарный Рейс ООО;ООО;0001002610;006101001;логистика +Янтарная Фабрика ООО;ООО;0001002628;006201001;производство +Янтарный Совет ООО;ООО;0001002635;006301001;услуги +Янтарный Бастион ООО;ООО;0001002642;006401001;строительство +Серебряный Сад АО;АО;0001002650;006501001;агро +Серебряный Магазин ООО;ООО;0001002667;006601001;торговля +Серебряный Рейс ООО;ООО;0001002674;006701001;логистика +Серебряная Фабрика ООО;ООО;0001002681;006801001;производство +Серебряный Совет ООО;ООО;0001002699;006901001;услуги +Серебряный Бастион АО;АО;0001002709;007001001;строительство +Северный Сад ООО;ООО;0001002716;007101001;агро +Северный Магазин ПАО;ПАО;0001002723;007201001;торговля +Северный Рейс ООО;ООО;0001002730;007301001;логистика +Северная Фабрика ООО;ООО;0001002748;007401001;производство +Северный Совет АО;АО;0001002755;007501001;услуги +Северный Бастион ООО;ООО;0001002762;007601001;строительство +Изумрудная Борозда ООО;ООО;0001002770;007701001;агро +Изумрудный Прилавок ООО;ООО;0001002787;007801001;торговля +Изумрудный Перевал ООО;ООО;0001002794;007901001;логистика +Изумрудный Горн АО;АО;0001002804;008001001;производство +Изумрудная Контора ООО;ООО;0001002811;008101001;услуги +Изумрудный Зодчий ООО;ООО;0001002829;008201001;строительство +Заревная Борозда ООО;ООО;0001002836;008301001;агро +Заревный Прилавок ООО;ООО;0001002843;008401001;торговля +Заревный Перевал АО;АО;0001002850;008501001;логистика +Заревный Горн ООО;ООО;0001002868;008601001;производство +Заревная Контора ООО;ООО;0001002875;008701001;услуги +Заревный Зодчий ООО;ООО;0001002882;008801001;строительство +Лазурное Поле ПАО;ПАО;0001002890;008901001;агро +Лазурный Пассаж АО;АО;0001002900;009001001;торговля +Лазурный Караван ООО;ООО;0001002917;009101001;логистика +Лазурная Верфь ООО;ООО;0001002924;009201001;производство +Лазурная Мастерская ООО;ООО;0001002931;009301001;услуги +Лазурная Арка ООО;ООО;0001002949;009401001;строительство +Рубиновое Поле АО;АО;0001002956;009501001;агро +Рубиновый Пассаж ООО;ООО;0001002963;009601001;торговля +Рубиновый Караван ООО;ООО;0001002970;009701001;логистика +Рубиновая Верфь ООО;ООО;0001002988;009801001;производство +Рубиновая Мастерская ООО;ООО;0001002995;009901001;услуги +Рубиновая Арка АО;АО;0001003004;000001001;строительство +Лунное Поле ООО;ООО;0001003011;000101001;агро +Лунный Пассаж ООО;ООО;0001003029;000201001;торговля +Лунный Караван ООО;ООО;0001003036;000301001;логистика +Лунная Верфь ООО;ООО;0001003043;000401001;производство +Лунная Мастерская АО;АО;0001003050;000501001;услуги +Лунная Арка ПАО;ПАО;0001003068;000601001;строительство +Янтарный Амбар ООО;ООО;0001003075;000701001;агро +Янтарный Товар ООО;ООО;0001003082;000801001;торговля +Янтарная Эстакада ООО;ООО;0001003090;000901001;логистика +Янтарная Мануфактура АО;АО;0001003100;001001001;производство +Янтарный Приём ООО;ООО;0001003117;001101001;услуги +Янтарный Мост ООО;ООО;0001003124;001201001;строительство +Серебряный Амбар ООО;ООО;0001003131;001301001;агро +Серебряный Товар ООО;ООО;0001003149;001401001;торговля +Серебряная Эстакада АО;АО;0001003156;001501001;логистика +Серебряная Мануфактура ООО;ООО;0001003163;001601001;производство +Серебряный Приём ООО;ООО;0001003170;001701001;услуги +Серебряный Мост ООО;ООО;0001003188;001801001;строительство +Северный Амбар ООО;ООО;0001003195;001901001;агро +Северный Товар АО;АО;0001003205;002001001;торговля +Северная Эстакада ООО;ООО;0001003212;002101001;логистика +Северная Мануфактура ООО;ООО;0001003220;002201001;производство +Северный Приём ПАО;ПАО;0001003237;002301001;услуги +Северный Мост ООО;ООО;0001003244;002401001;строительство +Изумрудная Нива АО;АО;0001003251;002501001;агро +Изумрудный Рынок ООО;ООО;0001003269;002601001;торговля +Изумрудный Путь ООО;ООО;0001003276;002701001;логистика +Изумрудный Завод ООО;ООО;0001003283;002801001;производство +Изумрудный Сервис ООО;ООО;0001003290;002901001;услуги +Изумрудный Фундамент АО;АО;0001003300;003001001;строительство +Авдонина Е.И. ИП;ИП;000010033189;;услуги +Бражников А.П. ИП;ИП;000010033252;;торговля +Ветрова Н.С. ИП;ИП;000010033326;;логистика +Голубев В.Д. ИП;ИП;000010033407;;агро +Дроздова О.А. ИП;ИП;000010033573;;строительство +Ермаков М.В. ИП;ИП;000010033647;;производство +Жарова И.Н. ИП;ИП;000010033710;;услуги +Зуев С.К. ИП;ИП;000010033894;;торговля +Игнатова Т.Р. ИП;ИП;000010033968;;логистика +Копылов Д.Л. ИП;ИП;000010034062;;агро +Лаврова Е.И. ИП;ИП;000010034136;;строительство +Мартынов А.П. ИП;ИП;000010034200;;производство +Норина Н.С. ИП;ИП;000010034383;;услуги +Овчаров В.Д. ИП;ИП;000010034457;;торговля +Пименова О.А. ИП;ИП;000010034520;;логистика +Рябов М.В. ИП;ИП;000010034601;;агро +Сорокина И.Н. ИП;ИП;000010034778;;строительство +Тарасов С.К. ИП;ИП;000010034841;;производство +Устинова Т.Р. ИП;ИП;000010034915;;услуги +Фролов Д.Л. ИП;ИП;000010035010;;торговля +Хромова Е.И. ИП;ИП;000010035193;;логистика +Цветаев А.П. ИП;ИП;000010035267;;агро +Чернова Н.С. ИП;ИП;000010035330;;строительство +Шаров В.Д. ИП;ИП;000010035404;;производство +Щукина О.А. ИП;ИП;000010035588;;услуги +Юдин М.В. ИП;ИП;000010035651;;торговля +Яковлева И.Н. ИП;ИП;000010035725;;логистика +Баранов С.К. ИП;ИП;000010035806;;агро +Волкова Т.Р. ИП;ИП;000010035972;;строительство +Гусев Д.Л. ИП;ИП;000010036077;;производство +Денисова Е.И. ИП;ИП;000010036140;;услуги +Елисеев А.П. ИП;ИП;000010036214;;торговля +Зайцева Н.С. ИП;ИП;000010036398;;логистика +Исаев В.Д. ИП;ИП;000010036461;;агро +Калинина О.А. ИП;ИП;000010036535;;строительство +Леонов М.В. ИП;ИП;000010036609;;производство +Маслова И.Н. ИП;ИП;000010036782;;услуги +Назаров С.К. ИП;ИП;000010036856;;торговля +Орлова Т.Р. ИП;ИП;000010036920;;логистика +Панов Д.Л. ИП;ИП;000010037024;;агро +Лазурный Банк АО;АО;0001003710;007101001;банк +Янтарный Банк ПАО;ПАО;0001003727;007201001;банк +Изумрудный Банк АО;АО;0001003734;007301001;банк +Рубиновый Банк ПАО;ПАО;0001003741;007401001;банк +Серебряный Банк АО;АО;0001003759;007501001;банк +Заревный Банк ПАО;ПАО;0001003766;007601001;банк +Лунный Банк АО;АО;0001003773;007701001;банк +Северный Банк ПАО;ПАО;0001003780;007801001;банк +Лазурный Кредитный Банк АО;АО;0001003798;007901001;банк +Янтарный Кредитный Банк ПАО;ПАО;0001003808;008001001;банк +Изумрудный Кредитный Банк АО;АО;0001003815;008101001;банк +Рубиновый Кредитный Банк ПАО;ПАО;0001003822;008201001;банк +Серебряный Кредитный Банк АО;АО;0001003830;008301001;банк +Заревный Кредитный Банк ПАО;ПАО;0001003847;008401001;банк +Лунный Кредитный Банк АО;АО;0001003854;008501001;банк +Северный Кредитный Банк ПАО;ПАО;0001003861;008601001;банк +Лазурный Деловой Банк АО;АО;0001003879;008701001;банк +Янтарный Деловой Банк ПАО;ПАО;0001003886;008801001;банк +Изумрудный Деловой Банк АО;АО;0001003893;008901001;банк +Рубиновый Деловой Банк ПАО;ПАО;0001003903;009001001;банк +Серебряный Деловой Банк АО;АО;0001003910;009101001;банк +Заревный Деловой Банк ПАО;ПАО;0001003928;009201001;банк +Лунный Деловой Банк АО;АО;0001003935;009301001;банк +Северный Деловой Банк ПАО;ПАО;0001003942;009401001;банк +Лазурный Земельный Банк АО;АО;0001003950;009501001;банк +Янтарный Земельный Банк ПАО;ПАО;0001003967;009601001;банк +Изумрудный Земельный Банк АО;АО;0001003974;009701001;банк +Рубиновый Земельный Банк ПАО;ПАО;0001003981;009801001;банк +Серебряный Земельный Банк АО;АО;0001003999;009901001;банк +Заревный Земельный Банк ПАО;ПАО;0001004008;000001001;банк diff --git a/synthgen/economy.py b/synthgen/economy.py new file mode 100644 index 0000000..1cc9ba2 --- /dev/null +++ b/synthgen/economy.py @@ -0,0 +1,453 @@ +"""Экономика компаний группы с внешними контрагентами (спека §5.2–5.3). + +Источник образца потоков и закрытия месяца: synthetic-group/gen/economy.py (#35); контрагенты здесь — из плана +`counterparties.plan`, а не фиксированные SUP_A/BUY_G. + +Порядок построения (на него опирается Task 4, операции внутри группы): + open_books(...) входящие остатки на book_start (31.12 перед первым периодом); + для каждого месяца: post_month(...) внешние операции месяца, + hook(...) операции внутри группы (extra), + close_month(...) закрытие 26/44 → 90, 90.01..90.08 → 90.09 → 99, 91 → 99, налог, в декабре 99 → 84. +`build_ledgers` — эта последовательность целиком. + +Аналитика контрагента: `An(cp=<ИНН Org>)` для внешних, `An(cp=<код члена>)` для компаний группы (последние — Task 4). +Модель компании (веса, классы контрагентов, профиль) строится один раз в `open_books` и лежит в `Ledger.meta["model"]`. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import ROUND_HALF_UP, Decimal +import math + +from synthgen.config import Config +from synthgen.counterparties import CounterpartyPlan +from synthgen.group import Group, Member +from synthgen.ledger import CENT, KIND_CLOSE_90, KIND_CLOSE_99, KIND_CLOSE_COST, An, Ledger, opening +from synthgen.periods import Period, book_end, book_start +from synthgen.rng import rng_for + +D = Decimal +VAT = D("0.2") +PROFIT_TAX = D("0.2") +SOCIAL = D("0.3") +GROWTH = D("0.07") # рост выручки в год +FNS = An("FNS") +PARETO_ALPHA = 1.2 + +# kind словаря → (доля закупок товара к выручке, доля расходов на продажу, доля зарплаты, ОС к годовой выручке) +KIND_PROFILE: dict[str, tuple[str, str, str, str]] = { + "агро": ("0.55", "0.05", "0.08", "0.5"), + "торговля": ("0.65", "0.07", "0.06", "0.3"), + "производство": ("0.50", "0.06", "0.10", "0.5"), + "строительство": ("0.50", "0.05", "0.12", "0.5"), + "логистика": ("0.35", "0.08", "0.12", "0.5"), + "услуги": ("0.15", "0.06", "0.20", "0.3"), +} +HEAD_PROFILE = ("0.10", "0.05", "0.25", "1.5") # головная — холдинг: малая выручка, большие активы +MEDIAN_REVENUE = 1_000_000_000 +HEAD_REVENUE = 50_000_000 +FLOW_ACCOUNTS = ("62", "60", "76") # с потоками по контрагентам (продажи, закупки, прочие поставщики) + + +def q(x: Decimal) -> Decimal: + return x.quantize(CENT, rounding=ROUND_HALF_UP) + + +def vat_of(gross: Decimal) -> Decimal: + return q(gross * 20 / 120) + + +def last_day(year: int, month: int) -> date: + return date(year + (month == 12), month % 12 + 1, 1) - timedelta(days=1) + + +def _prev(year: int, month: int) -> tuple[int, int]: + return (year - 1, 12) if month == 1 else (year, month - 1) + + +@dataclass +class FileModel: + """Контрагенты одного файла (компания × счёт): аналитика, веса Парето, класс поведения.""" + + ans: list[An | None] + w: list[Decimal] # по убыванию + cls: list[str] # normal (оплата в следующем месяце) / turn (оплата в месяце) / bal (только сальдо) / rev (аванс .02) + w_all: Decimal = D(0) + w_active: Decimal = D(0) + + def __post_init__(self) -> None: + self.w_all = sum(self.w, D(0)) + self.w_active = sum((w for w, c in zip(self.w, self.cls) if c in ("normal", "turn")), D(0)) + + +@dataclass +class CompanyModel: + code: str + seed: int + first_year: int + rev_year: Decimal + cogs: Decimal + sell: Decimal + salary: Decimal + ppe_ratio: Decimal + files: dict[str, FileModel] = field(default_factory=dict) + banks: dict[str, FileModel] = field(default_factory=dict) # "66"/"67" → банки и веса долей долга + + +def _weights(rng, n: int) -> list[Decimal]: + """Веса контрагентов по кривой Лоренца Парето: доля верхних p контрагентов = p^(1 − 1/α); α = 1.2 даёт + ~68 % оборота у верхних 10 %. Детерминированная кривая вместо выборки Парето (иначе доля верхних 10 % прыгает + от 23 % до 97 % по зерну), небольшой шум — чтобы веса разных файлов не совпадали.""" + expo = 1 - 1 / PARETO_ALPHA + shares = [((k / n) ** expo - ((k - 1) / n) ** expo) * rng.uniform(0.85, 1.15) for k in range(1, n + 1)] + return [D(max(1, round(w * 1e9))) for w in sorted(shares, reverse=True)] + + +def _classes(rng, acct: str, n: int) -> list[str]: + out = [] + for i in range(n): + u = rng.random() + if i < 3: # крупнейшие контрагенты — обычные: оплата в следующем месяце + out.append("normal") + elif u < 0.03: + out.append("rev" if acct in ("60", "62") else "bal") + elif u < 0.08: + out.append("bal") + elif u < 0.28: + out.append("turn") + else: + out.append("normal") + if acct in ("60", "62") and n >= 8 and "rev" not in out: + out[n // 2] = "rev" # хотя бы один контрагент с обратным знаком сальдо (аванс/переплата, §5.3) + return out + + +def _build_file(cfg: Config, code: str, acct: str, orgs: list) -> FileModel: + n = len(orgs) + if n == 0: # нет контрагентов в плане (mean = 0): операции идут без аналитики контрагента + return FileModel([None], [D(1)], ["normal"]) + ans = [An(cp=o.inn) for o in orgs] + return FileModel(ans, _weights(rng_for(cfg.seed, "cp-weights", code, acct), n), _classes(rng_for(cfg.seed, "cp-class", code, acct), acct, n)) + + +def _build_model(cfg: Config, plan: CounterpartyPlan, member: Member, first_year: int) -> CompanyModel: + code = member.code + scale = rng_for(cfg.seed, "scale", code) + if member.is_head: + rev = HEAD_REVENUE * math.exp(scale.gauss(0, 0.5)) + prof = HEAD_PROFILE + else: + rev = MEDIAN_REVENUE * math.exp(scale.gauss(0, math.log(10) / 2)) # ×10 в обе стороны на ±2σ + prof = KIND_PROFILE[member.org.kind] + rev = min(max(rev, 5e7), 2e10) + model = CompanyModel(code, cfg.seed, first_year, D(int(rev)), D(prof[0]), D(prof[1]), D(prof[2]), D(prof[3])) + for acct in ("58", *FLOW_ACCOUNTS): + model.files[acct] = _build_file(cfg, code, acct, plan.by_file.get((code, acct), [])) + for acct in ("66", "67"): + orgs = plan.by_file.get((code, acct), []) + if orgs: + model.banks[acct] = _build_file(cfg, code, acct, orgs) + return model + + +# ---------- суммы месяца ---------- + +def _noise(m: CompanyModel, tag: str, year: int, month: int) -> Decimal: + return D(rng_for(m.seed, "noise", m.code, tag, year, month).randint(92, 108)) / 100 + + +def _rev_month(m: CompanyModel, year: int, month: int) -> Decimal: + """Чистая выручка месяца (без НДС).""" + growth = 1 + GROWTH * (year - m.first_year) + return q(m.rev_year / 12 * growth * _noise(m, "rev", year, month)) + + +def _total(m: CompanyModel, acct: str, year: int, month: int) -> Decimal: + """Брутто-оборот месяца по счёту расчётов: 62 — продажи, 60 — закупки товара, 76 — прочие поставщики.""" + rev = _rev_month(m, year, month) + share = {"62": D(1), "60": m.cogs, "76": m.sell}[acct] + return q(rev * share * (1 + VAT)) + + +def _split(fm: FileModel, total: Decimal, classes: tuple[str, ...]) -> list[tuple[int, Decimal]]: + """Раскладка оборота по контрагентам указанных классов с весами Парето; нулевые суммы отбрасываются.""" + w_sum = sum((w for w, c in zip(fm.w, fm.cls) if c in classes), D(0)) + if w_sum == 0 or total <= 0: + return [] + out = [] + for i, (w, c) in enumerate(zip(fm.w, fm.cls)): + if c in classes: + a = q(total * w / w_sum) + if a > 0: + out.append((i, a)) + return out + + +def _side(fm: FileModel, ref: Decimal, i: int, factor: str) -> Decimal: + """Сальдо «без оборотов» контрагента: доля месячного оборота по весу среди всех контрагентов файла.""" + return q(ref * fm.w[i] / fm.w_all * D(factor)) + + +# ---------- входящие остатки ---------- + +def opening_lines(m: CompanyModel) -> list[tuple[str, An | None, Decimal]]: + py, pm = m.first_year - 1, 12 + ppe = q(m.rev_year * m.ppe_ratio) + lines: list[tuple[str, An | None, Decimal]] = [ + ("01.01", None, ppe), + ("02", None, -q(ppe * D("0.3"))), + ("51", None, q(m.rev_year * D("0.06"))), + ] + if m.cogs: + lines.append(("41", None, q(m.rev_year * m.cogs * D("0.15")))) + # 62: дебиторка — оплата прошлого месяца; bal — старая задолженность; rev — аванс (62.02, кредит) + for acct, sign, sub, sub_rev in (("62", 1, "62.01", "62.02"), ("60", -1, "60.01", "60.02"), ("76", -1, "76.05", None)): + fm = m.files[acct] + total = _total(m, acct, py, pm) + if acct != "62" and total == 0: + continue + ref = total if total else q(_rev_month(m, py, pm)) + for i, a in _split(fm, total, ("normal", "turn")): + if fm.cls[i] == "normal": # оплата в следующем месяце; turn закрыт в своём месяце + lines.append((sub, fm.ans[i], sign * a)) + for i, c in enumerate(fm.cls): + if c == "bal": + lines.append((sub, fm.ans[i], sign * _side(fm, ref, i, "0.5"))) + elif c == "rev" and sub_rev: + lines.append((sub_rev, fm.ans[i], -sign * _side(fm, ref, i, "0.3"))) + fm58 = m.files["58"] + ref58 = q(_rev_month(m, py, pm)) + for i, c in enumerate(fm58.cls): + if c in ("normal", "bal") and fm58.ans[i] is not None: + lines.append(("58.03", fm58.ans[i], _side(fm58, ref58, i, "3"))) + for acct, share in (("66", "0.12"), ("67", "0.08")): + fm = m.banks.get(acct) + if fm: + for i, a in enumerate(_split_all(fm, m.rev_year * D(share))): + if a > 0: + lines.append((f"{acct}.01", fm.ans[i], -a)) + lines.append(("80", None, -capital_of(m))) + return lines + + +def capital_of(m: CompanyModel) -> Decimal: + """Уставный капитал компании на открытии (2 % годовой выручки).""" + return q(m.rev_year * D("0.02")) + + +def _split_all(fm: FileModel, total: Decimal) -> list[Decimal]: + return [q(total * w / fm.w_all) for w in fm.w] + + +OpeningHook = Callable[[Member, dict[str, "CompanyModel"], list[tuple[str, An | None, Decimal]]], list[tuple[str, An | None, Decimal]]] + + +def open_books( + cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan, opening_hook: OpeningHook | None = None +) -> dict[str, Ledger]: + """Книги членов группы с входящими остатками на `book_start(ps)`; 84 — балансирующая строка. + + `opening_hook(member, модели всех членов, строки)` может заменить/добавить строки (операции внутри группы, Task 4); + 84 считается после него, поэтому книга остаётся сбалансированной.""" + day = book_start(ps) + ledgers: dict[str, Ledger] = {} + models = {mb.code: _build_model(cfg, plan, mb, ps[0].year) for mb in group.members} + for member in group.members: + model = models[member.code] + lg = Ledger(member.code) + lg.meta["model"] = model + lines = opening_lines(model) + if opening_hook is not None: + lines = opening_hook(member, models, lines) + total = sum((v for _, _, v in lines), D(0)) + if total <= 0: + raise ValueError(f"{member.code}: активы не больше обязательств, 84 стал бы дебетовым") + for acct, an, v in lines: + opening(lg, day, acct, an, v) + opening(lg, day, "84", None, -total) + ledgers[member.code] = lg + return ledgers + + +# ---------- операции месяца ---------- + +def _flow(lg: Ledger, m: CompanyModel, acct: str, year: int, month: int) -> None: + """Продажи (62), закупки товара (60) или прочие расходы (76) месяца и оплата контрагентам.""" + fm = m.files[acct] + py, pm = _prev(year, month) + d = lambda n: date(year, month, n) # noqa: E731 + cur = _split(fm, _total(m, acct, year, month), ("normal", "turn")) + prev = {i: a for i, a in _split(fm, _total(m, acct, py, pm), ("normal", "turn")) if fm.cls[i] == "normal"} + vat_total = D(0) + net_total = D(0) + sub = {"62": "62.01", "60": "60.01", "76": "76.05"}[acct] + for i, gross in cur: + an = fm.ans[i] + vat = vat_of(gross) + net = gross - vat + vat_total += vat + net_total += net + if acct == "62": + lg.post(d(10), sub, an, "90.01", None, gross, "реализация") + else: + exp = "41" if acct == "60" else "44" + lg.post(d(5), exp, None, sub, an, net, "закупка") + if vat > 0: + lg.post(d(5), "19", None, sub, an, vat, "НДС по закупке") + if vat_total > 0: + if acct == "62": + lg.post(d(10), "90.03", None, "68", FNS, vat_total, "НДС с реализации") + else: + lg.post(d(5), "68", FNS, "19", None, vat_total, "НДС к вычету") + if acct == "60" and net_total > 0: + lg.post(d(5), "90.02", None, "41", None, net_total, "себестоимость") + pay_day = d(20) if acct == "62" else d(15) + for i, gross in cur: + if fm.cls[i] == "turn": + _settle(lg, acct, sub, fm.ans[i], gross, pay_day) + for i, amount in prev.items(): + _settle(lg, acct, sub, fm.ans[i], amount, pay_day) + + +def _settle(lg: Ledger, acct: str, sub: str, an: An | None, amount: Decimal, day: date) -> None: + if acct == "62": + lg.post(day, "51", None, sub, an, amount, "поступление от покупателя") + else: + lg.post(day, sub, an, "51", None, amount, "оплата поставщику") + + +def _loans(lg: Ledger, m: CompanyModel, acct: str, year: int, month: int) -> None: + """Проценты банку по кредитам: начислены и уплачены в конце месяца (остаток по .02 не копится).""" + fm = m.banks.get(acct) + if not fm: + return + share = D("0.12") if acct == "66" else D("0.08") + day = date(year, month, 28) + for i, principal in enumerate(_split_all(fm, m.rev_year * share)): + interest = q(principal * D("0.12") / 12) + if interest > 0: + an = fm.ans[i] + lg.post(day, "91.02", An(an.cp, item="Проценты"), f"{acct}.02", an, interest, "проценты по кредиту") + lg.post(day, f"{acct}.02", an, "51", None, interest, "уплата процентов") + + +def _issued_loans(lg: Ledger, m: CompanyModel, year: int, month: int) -> None: + """58.03: часть контрагентов — займ выдан и возвращён в месяце (только обороты); остальные — сальдо с открытия.""" + fm = m.files["58"] + amounts = _split(fm, q(_rev_month(m, year, month) * D("0.05")), ("turn",)) + for i, a in amounts: + lg.post(date(year, month, 12), "58.03", fm.ans[i], "51", None, a, "выдан займ") + lg.post(date(year, month, 22), "51", None, "58.03", fm.ans[i], a, "возврат займа") + + +def post_month(ledgers: dict[str, Ledger], cfg: Config, group: Group, plan: CounterpartyPlan, year: int, month: int) -> None: + """Внешние операции месяца всех членов группы (до операций внутри группы и закрытия).""" + for code in sorted(ledgers): + lg = ledgers[code] + m: CompanyModel = lg.meta["model"] + d = lambda n: date(year, month, n) # noqa: E731 + for acct in ("60", "76", "62"): + if _total(m, acct, year, month) > 0: + _flow(lg, m, acct, year, month) + _issued_loans(lg, m, year, month) + sal = q(_rev_month(m, year, month) * m.salary) + if sal > 0: + soc = q(sal * SOCIAL) + lg.post(d(25), "26", None, "70", None, sal, "начислена зарплата") + lg.post(d(25), "26", None, "69", None, soc, "страховые взносы") + lg.post(d(26), "70", None, "51", None, sal, "выплата зарплаты") + lg.post(d(26), "69", None, "51", None, soc, "уплата взносов") + lg.post(d(28), "26", None, "02", None, q(m.rev_year * m.ppe_ratio / 120), "амортизация") + _loans(lg, m, "66", year, month) + _loans(lg, m, "67", year, month) + + +def _close_all(lg: Ledger, day: date, prefix: str) -> Decimal: + """Закрывает все субсчета prefix на 99; возвращает итог (Кт > 0 — прибыль).""" + result = D(0) + for (acct, an), bal in sorted(lg.balance(day, prefix).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): + if bal > 0: + lg.post(day, "99", None, acct, an, bal, "закрытие на 99", KIND_CLOSE_99) + else: + lg.post(day, acct, an, "99", None, -bal, "закрытие на 99", KIND_CLOSE_99) + result -= bal + return result + + +def _close_90(lg: Ledger, day: date) -> Decimal: + """Как в 1С: сальдо 90.01..90.08 переносятся на 90.09 (проводки `KIND_CLOSE_90`), затем 90.09 — на 99 + (`KIND_CLOSE_99`). Обороты 90.09 — только закрывающие; свёртка ОФР различает их по `Posting.kind`.""" + result = D(0) + for (acct, an), bal in sorted(lg.balance(day, "90").items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): + if acct == "90.09": + continue + if bal > 0: + lg.post(day, "90.09", None, acct, an, bal, "закрытие на 90.09", KIND_CLOSE_90) + else: + lg.post(day, acct, an, "90.09", None, -bal, "закрытие на 90.09", KIND_CLOSE_90) + result -= bal + bal09 = lg.balance(day, "90.09").get(("90.09", None), D(0)) + if bal09 > 0: + lg.post(day, "99", None, "90.09", None, bal09, "закрытие на 99", KIND_CLOSE_99) + elif bal09 < 0: + lg.post(day, "90.09", None, "99", None, -bal09, "закрытие на 99", KIND_CLOSE_99) + return result + + +def close_month(ledgers: dict[str, Ledger], year: int, month: int) -> None: + day = last_day(year, month) + for code in sorted(ledgers): + lg = ledgers[code] + vat_due = -lg.balance(day, "68").get(("68", FNS), D(0)) + if vat_due > 0: + lg.post(day, "68", FNS, "51", None, vat_due, "уплата НДС") + for cost, target in (("26", "90.08"), ("44", "90.07")): + for (acct, an), bal in lg.balance(day, cost).items(): + if bal > 0: + lg.post(day, target, None, acct, an, bal, "закрытие затрат", KIND_CLOSE_COST) + result = _close_90(lg, day) + _close_all(lg, day, "91") + tax = q(result * PROFIT_TAX) + if tax > 0: + lg.post(day, "99", None, "68.04", FNS, tax, "налог на прибыль") + elif tax < 0: + lg.post(day, "68.04", FNS, "99", None, -tax, "корректировка налога на прибыль") + if month % 3 == 0: + due = -lg.balance(day, "68.04").get(("68.04", FNS), D(0)) + if due > 0: + lg.post(day, "68.04", FNS, "51", None, due, "уплата налога на прибыль") + if month == 12: + profit = -lg.balance(day, "99").get(("99", None), D(0)) + if profit > 0: + lg.post(day, "99", None, "84", None, profit, "реформация баланса") + elif profit < 0: + lg.post(day, "84", None, "99", None, -profit, "реформация баланса") + + +def months(ps: list[Period]) -> list[tuple[int, int]]: + """Месяцы книги: с января года первого периода до конца последнего периода.""" + end = book_end(ps) + out = [] + y, mth = ps[0].year, 1 + while (y, mth) <= (end.year, end.month): + out.append((y, mth)) + y, mth = (y + 1, 1) if mth == 12 else (y, mth + 1) + return out + + +def build_ledgers( + cfg: Config, + group: Group, + ps: list[Period], + plan: CounterpartyPlan, + extra: Callable[[dict[str, Ledger], int, int], None] | None = None, + opening_hook: OpeningHook | None = None, +) -> dict[str, Ledger]: + """Книги всех членов: входящие остатки + помесячно (внешние операции, `extra`, закрытие).""" + ledgers = open_books(cfg, group, ps, plan, opening_hook) + for year, month in months(ps): + post_month(ledgers, cfg, group, plan, year, month) + if extra is not None: + extra(ledgers, year, month) + close_month(ledgers, year, month) + return ledgers diff --git a/synthgen/ecp.py b/synthgen/ecp.py new file mode 100644 index 0000000..bf1bd2b --- /dev/null +++ b/synthgen/ecp.py @@ -0,0 +1,29 @@ +"""Источник: synthetic-group/gen/ecp.py (#35). Выгрузка ЕЦП (спека §6.3): состав ГК, все компании — «Входит в ГСК». + +Формат сверен с импортом ядра (`arm-core/api/src/domain/directory_import.py`): обязательные колонки ИНН, +iD ГК, Группа компаний, Организация, Тип; необязательные Базовый РФ ГК в CRM, ГСК, ГСЗ, КПП. +""" + +from pathlib import Path + +import openpyxl + +from synthgen.group import Group +from synthgen.xlsx import save_deterministic + +HEADER = ["ИНН", "КПП", "iD ГК", "Базовый РФ ГК в CRM", "Группа компаний", "Организация", "ГСК", "ГСЗ", "Тип"] +BASE_RF = "Ростовский региональный филиал" # как в #35 + + +def write_ecp(group: Group, path: Path) -> None: + wb = openpyxl.Workbook() + ws = wb.active + assert ws is not None + ws.title = "Выписки" + ws.append(HEADER) + for m in group.members: + ws.append([m.inn, m.kpp, group.gk_id, BASE_RF, group.name, m.name, "Входит в ГСК", None, "ЮЛ"]) + for row in ws.iter_rows(min_row=2, max_col=2): + for cell in row: + cell.number_format = "@" # ИНН и КПП — текстом + save_deterministic(wb, path) diff --git a/synthgen/examples/default.yaml b/synthgen/examples/default.yaml new file mode 100644 index 0000000..c818075 --- /dev/null +++ b/synthgen/examples/default.yaml @@ -0,0 +1,12 @@ +# Настройки генератора синтетики по умолчанию (спека §3). +# Любой ключ переопределяется: --set ключ=значение. + +group_size: 5 # число компаний в ГК (все входят в ГСК), 2–40 +prefix: SYN # префикс ГК: 2–12 букв/цифр без пробелов; задаёт название группы и диапазон ИНН +periods: 4 # число отчётных периодов, 1–12 +start: 2025-Q1 # первый период, ГГГГ-Qn (2020-Q1 … 2030-Q4) +osv_counterparties_mean: 60 # среднее число внешних контрагентов в одной ОСВ, 0–150 +no_inn_companies: 1 # сколько компаний группы выгружают ОСВ без ИНН контрагентов, 0–group_size +vgo_mismatch_share: 0.1 # доля пар ВГО с расхождением сторон, 0–1 +seed: 1 # зерно случайности +out: null # каталог результата; null → ./out/ (или --out) diff --git a/synthgen/forms.py b/synthgen/forms.py new file mode 100644 index 0000000..75864aa --- /dev/null +++ b/synthgen/forms.py @@ -0,0 +1,299 @@ +"""ББ и ОФР из книги проводок (спека §5.6 п. 3 и 6, §6.2): сальдо на даты и обороты за периоды -> строки форм 66н. + +Правила свёртки счетов в строки — из `synthetic-group/gen/forms.py` (#35) без вариантов FOOD/SERV/ФСБУ 4 и без намеренных +ошибок форм. Книга ведётся в рублях; `balance_rub` / `income_rub` отдают рубли по кодам строк, `balance` / `income` — +готовые формы в тыс. руб. целыми с `ROUND_HALF_UP` построчно, итоги — суммы округлённых строк; 1600 = 1700 обеспечивается +подгонкой ±1 тыс. строкам с наименьшей потерей точности (каждая строка — не более одного раза; такие строки записаны в +`Form.plugged`). Расходы ОФР в форме отрицательные. + +Колонка берётся из книги, если её дата (ББ) или период (ОФР) лежит внутри книги; иначе — масштабированные значения первой +(книжной) колонки со множителем 0.8–1.1 на строку (решение 5), а в ББ строка 1370 уравнивает баланс (решение 35). +Закрывающие проводки месяца (`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.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) +THOUSAND = D(1000) + +RECEIVABLE_ROOTS = ("60", "62", "68", "69", "70", "71", "73", "75", "76") # Дт-сальдо -> 1230, Кт-сальдо -> 1520 + +BALANCE_CODES: tuple[str, ...] = ( + "1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190", "1100", + "1210", "1220", "1230", "1240", "1250", "1260", "1200", "1600", + "1310", "1320", "1340", "1350", "1360", "1370", "1300", + "1410", "1420", "1430", "1450", "1400", + "1510", "1520", "1530", "1540", "1550", "1500", "1700", +) # fmt: skip +INCOME_CODES: tuple[str, ...] = ( + "2110", "2120", "2100", "2210", "2220", "2200", "2310", "2320", "2330", "2340", "2350", "2300", + "2410", "2411", "2412", "2460", "2400", "2510", "2520", "2530", "2500", "2900", "2910", +) # fmt: skip +SECTION_TOTALS: dict[str, tuple[str, ...]] = { + "1100": ("1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190"), + "1200": ("1210", "1220", "1230", "1240", "1250", "1260"), + "1300": ("1310", "1320", "1340", "1350", "1360", "1370"), + "1400": ("1410", "1420", "1430", "1450"), + "1500": ("1510", "1520", "1530", "1540", "1550"), +} +ASSET_LEAVES = SECTION_TOTALS["1100"] + SECTION_TOTALS["1200"] +LIABILITY_LEAVES = SECTION_TOTALS["1300"] + SECTION_TOTALS["1400"] + SECTION_TOTALS["1500"] +BALANCING_LEAF = "1370" # уравнивает баланс масштабированных колонок +INCOME_LEAVES = ("2110", "2120", "2210", "2220", "2310", "2320", "2330", "2340", "2350", "2410") # со знаком формы +SCALE_MIN, SCALE_MAX = 8000, 11000 # множитель 0.8–1.1 в десятитысячных + + +@dataclass(frozen=True) +class Form: + kind: str # BALANCE | INCOME + member: Member + period: Period + columns: list[str] # подписи колонок + values: dict[str, list[Decimal]] # код -> значения колонок, тыс. руб. целые; расходы ОФР отрицательные + dates: list[date] # ББ: даты колонок; ОФР: конец периода каждой колонки + from_book: list[bool] # колонка получена из книги (иначе масштабирована) + plugged: list[tuple[str, ...]] = field(default_factory=list) # ББ: строки, подогнанные ±1 в каждой колонке + + +# ---------------------------------------------------------------- рубли + +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"): + return "1150", bal + if root == "08": + return "1190", bal + if root in ("10", "20", "26", "41", "43", "44"): + return "1210", bal + if root == "97": + return "1260", bal + if root == "19": + return "1220", bal + if root in ("50", "51", "52", "55"): + return "1250", bal + if acct == "58.01": + return "1170", bal + if acct == "58.03": + # решение 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 # арендные обязательства считаем долгосрочными + if bal > 0: + return "1230", bal + return "1520", -bal + if root in ("66", "67") and bal < 0: + return ("1510" if root == "66" else "1410"), -bal + if root in ("80", "84", "99", "82", "83") and bal != 0: + return {"80": "1310", "82": "1360", "83": "1350"}.get(root, "1370"), -bal + if root == "96": + return "1540", -bal + raise ValueError(f"{member.code}: нет правила строки ББ для {acct} (сальдо {bal})") + + +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, 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, an, bal, member, seed) + leaves[code] += amount + return leaves + + +def _add_totals(values: dict[str, Decimal]) -> dict[str, Decimal]: + out = dict(values) + for total, parts in SECTION_TOTALS.items(): + out[total] = sum((out[c] for c in parts), ZERO) + out["1600"] = out["1100"] + out["1200"] + out["1700"] = out["1300"] + out["1400"] + out["1500"] + return out + + +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, 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} + + +def _monthly_income(ledger: Ledger) -> dict[tuple[int, int], dict[str, Decimal]]: + """Обороты 90/91/99 по месяцам, рубли со знаком формы (доходы +, расходы −, 2410 = −налог); кэш в книге.""" + cached = ledger._cache.get("forms_income") + if cached is not None: + return cached + out: dict[tuple[int, int], dict[str, Decimal]] = {} + for p in ledger.postings: + if p.kind in (KIND_CLOSE_90, KIND_CLOSE_99): + continue + row = out.setdefault((p.day.year, p.day.month), {}) + for acct, an, sign in ((p.dt, p.dt_an, 1), (p.ct, p.ct_an, -1)): # Дт +, Кт −; в строку идёт −сальдо + item = an.item if an else None + code = {"90.01": "2110", "90.03": "2110", "90.02": "2120", "90.07": "2210", "90.08": "2220"}.get(acct) + if acct == "91.01": + code = {"Дивиденды": "2310", "Проценты": "2320"}.get(item, "2340") + elif acct == "91.02": + code = "2330" if item == "Проценты" else "2350" + if code: + row[code] = row.get(code, ZERO) - sign * p.amount + if p.dt == "99" and p.ct == "68.04": # налог на прибыль: расход — минус, корректировка — плюс + row["2410"] = row.get("2410", ZERO) - p.amount + elif p.dt == "68.04" and p.ct == "99": + row["2410"] = row.get("2410", ZERO) + p.amount + ledger._cache["forms_income"] = out + return out + + +def income_rub(ledger: Ledger, start: date, end: date) -> dict[str, Decimal]: + """Строки-листья ОФР за период в рублях со знаком формы (расходы отрицательные).""" + if (start.month, start.day) != (1, 1) or (end + date.resolution).day != 1: + raise ValueError(f"период {start}..{end} не выровнен по месяцам") + monthly = _monthly_income(ledger) + res = {c: ZERO for c in INCOME_LEAVES} + y, m = start.year, start.month + while (y, m) <= (end.year, end.month): + for code, v in monthly.get((y, m), {}).items(): + res[code] += v + y, m = (y + 1, 1) if m == 12 else (y, m + 1) + return res + + +# ---------------------------------------------------------------- единицы формы + +def _thousands(x: Decimal) -> Decimal: + return (x / THOUSAND).quantize(ONE, rounding=ROUND_HALF_UP) + + +def _plug(rub: dict[str, Decimal]) -> tuple[dict[str, Decimal], tuple[str, ...]]: + """Округление листьев ББ и подгонка так, чтобы актив и пассив сошлись (как «бухгалтерская» подгонка 1С): ±1 тыс. строкам + с наименьшей потерей точности среди строк с ненулевой суммой, каждой строке — не более одного раза.""" + out = {c: _thousands(rub[c]) for c in ASSET_LEAVES + LIABILITY_LEAVES} + diff = sum((out[c] for c in ASSET_LEAVES), ZERO) - sum((out[c] for c in LIABILITY_LEAVES), ZERO) + if diff == 0: + return out, () + step = -ONE if diff > 0 else ONE # шаг строке актива; пассиву — наоборот + cands = [] + for c in ASSET_LEAVES + LIABILITY_LEAVES: + 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)) + if need > len(cands): + raise ValueError(f"нечего подгонять: расхождение {diff} тыс., строк {len(cands)}") + for _, c, delta in cands[:need]: + out[c] += delta + return out, tuple(sorted(c for _, c, _ in cands[:need])) + + +def _scale(rng_keys: tuple, code: str, value: Decimal, seed: int, kind: str) -> Decimal: + factor = D(rng_for(seed, "prev", code, kind, *rng_keys).randint(SCALE_MIN, SCALE_MAX)) / D(10000) + return value * factor + + +# ---------------------------------------------------------------- формы + +_MONTHS_WORDS = {1: "3 месяца", 2: "6 месяцев", 3: "9 месяцев"} + + +def balance_dates(period: Period) -> list[date]: + return [period.end, date(period.year - 1, 12, 31), date(period.year - 2, 12, 31)] + + +def income_ends(period: Period) -> list[date]: + return [period.end, date(period.year - 1, period.end.month, period.end.day)] + + +def _balance_titles(period: Period) -> list[str]: + first = f"На 31 декабря\n{period.year} года" if period.quarter == 4 else f"За {_MONTHS_WORDS[period.quarter]}\n{period.year} года" + return [first, f"На 31 декабря\n{period.year - 1} года", f"На 31 декабря\n{period.year - 2} года"] + + +def _income_titles(period: Period) -> list[str]: + if period.quarter == 4: + return [f"За {period.year} год", f"За {period.year - 1} год"] + words = _MONTHS_WORDS[period.quarter] + return [f"За {words}\n{period.year} года", f"За {words}\n{period.year - 1} года"] + + +def balance(ledger: Ledger, member: Member, period: Period, ps: list[Period], seed: int) -> Form: + """ББ за период: на конец периода, на 31.12 прошлого года и позапрошлого (в книге — из книги, иначе масштаб).""" + dates = balance_dates(period) + lo, hi = book_start(ps), book_end(ps) + cols: list[dict[str, Decimal]] = [] + from_book: list[bool] = [] + plugged: list[tuple[str, ...]] = [] + base_rub: dict[str, Decimal] | None = None + for i, day in enumerate(dates): + if lo <= day <= hi: + rub = balance_rub(ledger, day, member, seed) + base_rub = base_rub or rub + values, plug = _plug(rub) + from_book.append(True) + else: + assert base_rub is not None, "первая колонка ББ всегда в книге" + scaled = {c: _scale((member.code, period.key, i), c, base_rub[c], seed, "BALANCE") for c in ASSET_LEAVES + LIABILITY_LEAVES} + others = sum((scaled[c] for c in LIABILITY_LEAVES if c != BALANCING_LEAF), ZERO) + scaled[BALANCING_LEAF] = sum((scaled[c] for c in ASSET_LEAVES), ZERO) - others + values, plug = _plug(scaled) + from_book.append(False) + cols.append(_add_totals(values)) + plugged.append(plug) + return Form( + "BALANCE", member, period, _balance_titles(period), {c: [col[c] for col in cols] for c in BALANCE_CODES}, dates, + from_book, plugged, + ) # fmt: skip + + +def _income_column(leaves: dict[str, Decimal]) -> dict[str, Decimal]: + r = {c: _thousands(leaves[c]) for c in INCOME_LEAVES} + r["2100"] = r["2110"] + r["2120"] + r["2200"] = r["2100"] + r["2210"] + r["2220"] + r["2300"] = r["2200"] + r["2310"] + r["2320"] + r["2330"] + r["2340"] + r["2350"] + r["2411"] = r["2410"] # налог — текущий; отложенного в модели нет + r["2412"] = r["2460"] = r["2510"] = r["2520"] = r["2530"] = r["2900"] = r["2910"] = ZERO + r["2400"] = r["2300"] + r["2410"] + r["2460"] + r["2500"] = r["2400"] + r["2510"] + r["2520"] + r["2530"] + return r + + +def income(ledger: Ledger, member: Member, period: Period, ps: list[Period], seed: int) -> Form: + """ОФР за период и за тот же период прошлого года (в книге — из книги, иначе масштаб первой колонки).""" + ends = income_ends(period) + lo, hi = date(ps[0].year, 1, 1), book_end(ps) + cols: list[dict[str, Decimal]] = [] + from_book: list[bool] = [] + base: dict[str, Decimal] | None = None + for i, end in enumerate(ends): + start = date(end.year, 1, 1) + if lo <= start and end <= hi: + leaves = income_rub(ledger, start, end) + base = base or leaves + from_book.append(True) + else: + assert base is not None, "первая колонка ОФР всегда в книге" + leaves = {c: _scale((member.code, period.key, i), c, base[c], seed, "INCOME") for c in INCOME_LEAVES} + from_book.append(False) + cols.append(_income_column(leaves)) + return Form("INCOME", member, period, _income_titles(period), {c: [col[c] for col in cols] for c in INCOME_CODES}, ends, from_book) diff --git a/synthgen/forms_xlsx.py b/synthgen/forms_xlsx.py new file mode 100644 index 0000000..d5cc3f0 --- /dev/null +++ b/synthgen/forms_xlsx.py @@ -0,0 +1,109 @@ +"""ББ и ОФР в Excel по шаблону 1С (спека §6.2): копия книги образца (`templates/bb_1c.xlsx`, `ofr_1c.xlsx`) с подстановкой +реквизитов и значений. Значения ищутся по колонке «Коды» (и номерам колонок под заголовком), а не по номерам строк; +положение реквизитов шапки — таблица `HEADER_CELLS` (шаблон наш, собран `templates/build_forms_templates.py`). + +Значения — целые тыс. руб., расходы ОФР — отрицательные числа (решение 12); ноль пишется `0` с форматом `[=0]"-";General`, +остальное — `#,##0`, как в выгрузке 1С. +""" + +from pathlib import Path + +import openpyxl + +from synthgen import xlsx +from synthgen.forms import Form +from synthgen.rng import stable_hash + +TEMPLATES = Path(__file__).with_name("templates") +TEMPLATE_FILES = {"BALANCE": "bb_1c.xlsx", "INCOME": "ofr_1c.xlsx"} +ZERO_FORMAT = '[=0]"-";General' +NUMBER_FORMAT = "#,##0" +HEADER_CELLS = { + "BALANCE": { + "title": "B3", "org": "G7", "okpo": "R7", "inn": "R8", "activity": "G9", "okved": "R9", "legal_form": "G10", "okopf": "R10", + "okfs": "S10", "address": "G13", "date": ("R6", "S6", "T6"), "head": "Q80", "accountant": "Q83", + }, + "INCOME": { + "title": "B3", "org": "G7", "okpo": "P7", "inn": "P8", "activity": "G9", "okved": "P9", "legal_form": "G10", "okopf": "P10", + "okfs": "Q10", "address": None, "date": ("P6", "Q6", "R6"), "head": "O49", "accountant": "O52", + }, +} # fmt: skip +ACTIVITY = { + "агро": ("Сельское хозяйство", "01.11.11"), "торговля": ("Торговля оптовая", "46.90"), + "логистика": ("Деятельность автомобильного грузового транспорта", "49.41"), "производство": ("Производство прочих изделий", "25.99"), + "строительство": ("Строительство зданий", "41.20"), "услуги": ("Деятельность консультативная", "70.22"), +} # fmt: skip +LEGAL_FORM = {"ООО": ("ООО - общество с ограниченной ответственностью", "65"), "АО": ("АО - акционерное общество", "47")} +STREETS = ("Ленина", "Советская", "Мира", "Заводская", "Промышленная", "Центральная", "Садовая", "Новая") +CITIES = ("г. Тверь", "г. Липецк", "г. Курск", "г. Тула", "г. Рязань", "г. Орёл", "г. Тамбов", "г. Калуга") +SURNAMES = ("Ветров", "Озеров", "Лугов", "Дубравин", "Ключников", "Ракитин", "Соловьёв", "Березин", "Туманов", "Зорин") +FEMALE_SURNAMES = ("Ветрова", "Озерова", "Лугова", "Дубравина", "Ключникова", "Ракитина", "Соловьёва", "Березина", "Туманова", "Зорина") +MALE_NAMES = ("Андрей Павлович", "Игорь Сергеевич", "Олег Николаевич", "Максим Андреевич", "Денис Олегович") +FEMALE_NAMES = ("Анна Игоревна", "Ольга Павловна", "Елена Сергеевна", "Марина Андреевна", "Татьяна Олеговна") + + +def file_name(form: Form) -> str: + prefix = "ББ" if form.kind == "BALANCE" else "ОФР" + return f"{prefix}_{form.period.label_1c[:-1]}.xlsx" # метка периода кончается точкой — она же перед расширением + + +def _person(form: Form, role: str) -> str: + key = f"{form.member.code}|{form.period.key}|{role}" + if stable_hash(key + "|sex", 2): + return f"{FEMALE_SURNAMES[stable_hash(key + '|s', 10)]} {FEMALE_NAMES[stable_hash(key + '|n', 5)]}" + return f"{SURNAMES[stable_hash(key + '|s', 10)]} {MALE_NAMES[stable_hash(key + '|n', 5)]}" + + +def _requisites(form: Form) -> dict[str, object]: + m = form.member + activity, okved = ACTIVITY.get(m.org.kind, ACTIVITY["услуги"]) + legal, okopf = LEGAL_FORM.get(m.org.form, LEGAL_FORM["АО"]) + h = stable_hash(m.inn, 10**6) + address = f"{100000 + stable_hash(m.inn + 'z', 899999)}, {CITIES[h % 8]}, ул. {STREETS[(h // 8) % 8]}, дом № {1 + h % 97}" + end = form.period.end + return { + "title": f"за {form.period.label_1c}", "org": m.name, "okpo": f"{stable_hash(m.inn + 'okpo', 10**8):08d}", "inn": m.inn, + "activity": activity, "okved": okved, "legal_form": legal, "okopf": okopf, "okfs": "16", "address": address, + "head": _person(form, "head"), "accountant": _person(form, "accountant"), "date": (end.day, end.month, end.year), + } # fmt: skip + + +def _tables(ws) -> list[tuple[int, list[int], int]]: + """Таблицы формы по заголовку «Коды»: (колонка кодов, колонки значений, строка заголовка).""" + found = [] + for row in ws.iter_rows(): + for cell in row: + if cell.value == "Коды": + cols = [c.column for c in ws[cell.row + 1] if c.value is not None and c.column > cell.column] + found.append((cell.column, cols, cell.row)) + return found + + +def write(form: Form, path: Path) -> None: + wb = openpyxl.load_workbook(TEMPLATES / TEMPLATE_FILES[form.kind]) + ws = wb.active + cells = HEADER_CELLS[form.kind] + req = _requisites(form) + for key, coord in cells.items(): + if coord is None: + continue + if key == "date": + for c, v in zip(coord, req["date"], strict=True): + ws[c].value = v + else: + ws[coord].value = req[key] + for code_col, value_cols, head in _tables(ws): + if len(value_cols) != len(form.columns): + raise ValueError(f"в шаблоне {len(value_cols)} колонок значений, в форме {len(form.columns)}") + for col, title in zip(value_cols, form.columns, strict=True): + ws.cell(head, col).value = title + for r in range(head + 2, ws.max_row + 1): + code = ws.cell(r, code_col).value + if not (isinstance(code, str) and code.isdigit() and len(code) == 4): + continue + for i, col in enumerate(value_cols): + value = int(form.values[code][i]) + cell = ws.cell(r, col) + cell.value = value + cell.number_format = ZERO_FORMAT if value == 0 else NUMBER_FORMAT + xlsx.save_deterministic(wb, path) diff --git a/synthgen/generate.py b/synthgen/generate.py new file mode 100644 index 0000000..86c7aab --- /dev/null +++ b/synthgen/generate.py @@ -0,0 +1,92 @@ +"""Сборка набора (спека §7): группа → периоды → книги → файлы ЕЦП, ОСВ, ББ и ОФР, config.yaml и README прогона.""" + +import shutil +from dataclasses import dataclass, field, replace +from pathlib import Path + +from synthgen import dictionary, ecp, forms, forms_xlsx, osv, report +from synthgen.config import Config, dump_config +from synthgen.counterparties import OSV_ACCOUNTS, CounterpartyPlan +from synthgen.counterparties import plan as make_plan +from synthgen.group import build_group +from synthgen.intragroup import VgoPair, build +from synthgen.periods import build_periods + + +@dataclass +class Manifest: + files: list[Path] = field(default_factory=list) + pairs: list[VgoPair] = field(default_factory=list) + capped: bool = False + + +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 перезапишет прежний набор)") + 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) + + +def generate(cfg: Config, out: Path, *, force: bool = False) -> Manifest: + """Строит набор в каталоге `out`; непустой `out` без `force` — FileExistsError.""" + _prepare(out, force) + group = build_group(cfg, dictionary.load()) + periods = build_periods(cfg.start, cfg.periods) + plan: CounterpartyPlan = make_plan(cfg, group) + books, pairs = build(cfg, group, periods, plan) + manifest = Manifest(pairs=pairs, capped=plan.capped) + + def keep(path: Path) -> Path: + manifest.files.append(path) + return path + + ecp.write_ecp(group, keep(out / "directories" / f"ecp-{cfg.prefix}.xlsx")) + for member in group.members: + ledger = books[member.code] + with_inn = member.code not in group.no_inn + for period in periods: + for account in OSV_ACCOUNTS: + data = osv.rows(ledger, account, period, member, group) + path = out / "osv" / member.code / period.key / osv.file_name(account, period) + osv.write(path, member, account, period, data, with_inn=with_inn) + keep(path) + for form in ( + forms.balance(ledger, member, period, periods, cfg.seed), + forms.income(ledger, member, period, periods, cfg.seed), + ): + path = out / "statements" / member.code / period.key / forms_xlsx.file_name(form) + forms_xlsx.write(form, path) + keep(path) + (out / "config.yaml").write_text(dump_config(replace(cfg, out=None)), encoding="utf-8") + keep(out / "config.yaml") + readme = out / "README.md" + manifest.files.append(readme) + readme.write_text(report.render(cfg, group, pairs, plan, manifest), encoding="utf-8") + return manifest diff --git a/synthgen/group.py b/synthgen/group.py new file mode 100644 index 0000000..19481f2 --- /dev/null +++ b/synthgen/group.py @@ -0,0 +1,118 @@ +"""Состав группы компаний (спека §4.2): члены из словаря, ИНН по префиксу, доли, компании без ИНН в ОСВ.""" + +from dataclasses import dataclass +from decimal import Decimal + +from synthgen import inn +from synthgen.config import Config +from synthgen.dictionary import Org +from synthgen.rng import rng_for, stable_hash + +_HASH_RANGE = 90000 +_CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class Member: + code: str # C01, C02, … + prefix: str # префикс ГК + org: Org # запись словаря, давшая корень имени + name: str # АО " <корень>" + name_1c: str # <корень> АО — как 1С пишет контрагента + inn: str + kpp: str + is_head: bool + head_share: Decimal | None # доля головной в дочерней; у головной None + minority: Org | None # миноритарий дочерней (внешний держатель остальной доли) + + +@dataclass(frozen=True) +class Group: + prefix: str + name: str # ГК + gk_id: str # -000001 + members: list[Member] + no_inn: frozenset[str] # коды компаний, чья ОСВ записана без ИНН + variant_name: dict[str, str] # код ОСВ-компании без ИНН → код члена группы, записанного другим написанием + externals: list[Org] # словарь без банков и без выбранных в группу + banks: list[Org] + + +def member_inn(prefix: str, i: int) -> str: + """ИНН i-й компании группы (i от 1, < 100): номер = 1000000 + 100 × h(prefix) + i.""" + return inn.make_inn10(1000000 + 100 * stable_hash(prefix, _HASH_RANGE) + i) + + +def member_kpp(i: int) -> str: + return f"00{i:02d}01001" + + +def variant_spelling(member: Member) -> str: + """Вариантное написание члена группы в ОСВ без ИНН: корень, префикс, форма без кавычек (решение 17).""" + return f"{member.org.root} {member.prefix} {member.org.form}" + + +def build_group(cfg: Config, orgs: list[Org]) -> Group: + rng = rng_for(cfg.seed, "group", cfg.prefix) + candidates = [o for o in orgs if o.form in ("ООО", "АО") and o.kind != "банк"] + heads = [o for o in candidates if o.form == "АО"] + head_org = rng.choice(heads) + rest = [o for o in candidates if o is not head_org] + chosen = [head_org, *rng.sample(rest, cfg.group_size - 1)] + + children = list(range(1, cfg.group_size)) + with_minority = set(rng.sample(children, min(len(children), rng.randint(1, 2)))) if children else set() + chosen_set = {o.inn for o in chosen} + pool = [o for o in orgs if o.kind != "банк" and o.form != "ИП" and o.inn not in chosen_set] + + members: list[Member] = [] + for idx, org in enumerate(chosen): + i = idx + 1 + share: Decimal | None = None + minority: Org | None = None + if idx > 0: + if idx in with_minority: + share = (Decimal(rng.randint(51, 99)) / 100).quantize(_CENT) + minority = rng.choice(pool) + else: + share = Decimal("1.00") + members.append( + Member( + code=f"C{i:02d}", + prefix=cfg.prefix, + org=org, + name=f'{org.form} "{cfg.prefix} {org.root}"', + name_1c=f"{cfg.prefix} {org.root} {org.form}", + inn=member_inn(cfg.prefix, i), + kpp=member_kpp(i), + is_head=idx == 0, + head_share=share, + minority=minority, + ) + ) + + codes = [m.code for m in members] + child_codes = codes[1:] + n = cfg.no_inn_companies + picked = rng.sample(child_codes, min(n, len(child_codes))) + if n > len(child_codes): + picked.append(codes[0]) + no_inn = frozenset(picked) + + variant_name: dict[str, str] = {} + for code in sorted(no_inn): + others = [c for c in codes if c != code] + variant_name[code] = rng.choice(others) + + externals = [o for o in orgs if o.kind != "банк" and o.inn not in chosen_set] + banks = [o for o in orgs if o.kind == "банк"] + return Group( + prefix=cfg.prefix, + name=f"ГК {cfg.prefix}", + gk_id=f"{cfg.prefix}-000001", + members=members, + no_inn=no_inn, + variant_name=variant_name, + externals=externals, + banks=banks, + ) diff --git a/synthgen/inn.py b/synthgen/inn.py new file mode 100644 index 0000000..c1a5ccd --- /dev/null +++ b/synthgen/inn.py @@ -0,0 +1,36 @@ +"""Источник: synthetic-group/gen/inn.py (#35). + +Вымышленные ИНН: регион `00` (не существует), верная контрольная сумма ФНС. Без случайности.""" + +_W10 = (2, 4, 10, 3, 5, 9, 4, 6, 8) +_W12_1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_W12_2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) + + +def _check(digits: list[int], weights: tuple[int, ...]) -> int: + return sum(d * w for d, w in zip(digits, weights)) % 11 % 10 + + +def make_inn10(seq: int) -> str: + """ИНН юрлица: `00` + 7 цифр номера + контрольная цифра.""" + body = [int(c) for c in f"00{seq:07d}"] + return "".join(map(str, body)) + str(_check(body, _W10)) + + +def make_inn12(seq: int) -> str: + """ИНН физлица/ИП: `00` + 8 цифр номера + две контрольные цифры.""" + body = [int(c) for c in f"00{seq:08d}"] + body.append(_check(body, _W12_1)) + body.append(_check(body, _W12_2)) + return "".join(map(str, body)) + + +def is_valid(inn: str) -> bool: + if not inn.isdigit(): + return False + digits = [int(c) for c in inn] + if len(digits) == 10: + return digits[9] == _check(digits[:9], _W10) + if len(digits) == 12: + return digits[10] == _check(digits[:10], _W12_1) and digits[11] == _check(digits[:11], _W12_2) + return False diff --git a/synthgen/intragroup.py b/synthgen/intragroup.py new file mode 100644 index 0000000..1f9f8f4 --- /dev/null +++ b/synthgen/intragroup.py @@ -0,0 +1,358 @@ +"""Операции внутри группы и расхождения ВГО (спека §5.4–5.5). + +Операции проводятся в двух книгах одновременно и симметрично: +- доли: входящие остатки, 58.01 у головной по дочерней ↔ 80.09 у дочерней по головной (миноритарий — только в 80.09); +- займы: головная → каждая дочерняя, 58.03 ↔ 66.03/67.03 (входящие остатки), проценты ежемесячно 76.09 ↔ 76.09 / 91; +- торговля: 62.01 продавца ↔ 60.01 покупателя, оплата частично (остаток на конец периода — сальдо пары); +- дивиденды: во 2 квартале (май), объявлены и выплачены в одном месяце (решение 11): сальдо 76.03 на конец периода 0; +- расхождения: в последнем месяце первого периода года одна сторона проводит «накладную в пути» (торговля) или + транш «в пути» по основному долгу (заём; проценты — симметрично, расхождений не дают), вторая проводит её 31 января + следующего года (если январь есть в книге, решение 13). + +Аналитика группы — `An(cp=<код члена>)`; внешние контрагенты в 60/62 остаются с `An(cp=<ИНН>)`. +""" + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +import math + +from synthgen.config import Config +from synthgen.counterparties import CounterpartyPlan +from synthgen.economy import ( + FNS, + CompanyModel, + build_ledgers, + capital_of, + last_day, + q, + vat_of, +) +from synthgen.group import Group, Member +from synthgen.ledger import An, Ledger +from synthgen.periods import Period +from synthgen.rng import rng_for + +D = Decimal +THRESHOLD = D(5) # порог ДП §4, процентов +LOAN_RATE = D("0.10") # годовая ставка по займам группы +DIVIDEND_MONTH = 5 # 2 квартал +DIVIDEND_SHARE = D("0.03") # дивиденды дочерней: доля годовой выручки +BUYER_CAP = D("0.10") # закупки у группы не больше доли годовой выручки покупателя (с НДС) +SELLER_CAP = D("0.40") # ВГО-выручка продавца не больше доли его годовой выручки (с НДС), решение 32 + + +@dataclass(frozen=True) +class Mismatch: + side: str # код стороны, у которой проводка есть + year: int + period_key: str + amount: Decimal + pct: Decimal # процентов от меньшей стороны сальдо пары на конец периода + above_threshold: bool + + +@dataclass(frozen=True) +class VgoPair: + a: str # продавец / займодавец + b: str + kind: str # trade | loan + mismatch: Mismatch | None = None # расхождение первого из лет (для удобства; все — в `mismatches`) + mismatches: tuple[Mismatch, ...] = () # по одному на год, где пара выбрана (решение 27) + + +def loan_account_for_seed(seed: int, child_code: str) -> str: + """Субсчёт кредитора по займу головной: краткосрочный 66.03 или долгосрочный 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]: + """Пары ВГО без расхождений: торговля — упорядоченная пара с вероятностью 0.5; займы — головная → дочерние.""" + pairs: list[VgoPair] = [] + for a in group.members: + for b in group.members: + if a.code != b.code and rng_for(cfg.seed, "trade", a.code, b.code).random() < 0.5: + pairs.append(VgoPair(a.code, b.code, "trade")) + head = group.members[0] + for child in group.members[1:]: + pairs.append(VgoPair(head.code, child.code, "loan")) + return pairs + + +# ---------- входящие остатки: доли и займы ---------- + +def _loan_principal(cfg: Config, child: CompanyModel) -> Decimal: + return q(child.rev_year * D(rng_for(cfg.seed, "loan-principal", child.code).randint(30, 60)) / 1000) + + +def _head_part(member: Member, cap: Decimal) -> Decimal: + return q(cap * (member.head_share or D(1))) + + +def _opening_hook(cfg: Config, group: Group): + head = group.members[0] + children = {m.code: m for m in group.members[1:]} + + def hook(member: Member, models: dict[str, CompanyModel], lines): + if member.is_head: + out = list(lines) + for code, child in children.items(): + cm = models[code] + out.append(("58.01", An(cp=code), _head_part(child, capital_of(cm)))) + out.append(("58.03", An(cp=code), _loan_principal(cfg, cm))) + return out + cm = models[member.code] + cap = capital_of(cm) + out = [ln for ln in lines if ln[0] != "80"] + part = _head_part(member, cap) + out.append(("80.09", An(cp=head.code), -part)) + if cap - part > 0: + if member.minority is None: + raise ValueError(f"{member.code}: доля головной {member.head_share} без миноритария") + out.append(("80.09", An(cp=member.minority.inn), -(cap - part))) + out.append((loan_account(cfg, member.code), An(cp=head.code), -_loan_principal(cfg, cm))) + return out + + return hook + + +# ---------- проводки сторон ---------- + +def _seller_invoice(lg: Ledger, day: date, buyer: str, gross: Decimal, memo: str) -> None: + lg.post(day, "62.01", An(cp=buyer), "90.01", None, gross, memo) + vat = vat_of(gross) + if vat > 0: + lg.post(day, "90.03", None, "68", FNS, vat, "НДС с реализации") + + +def _buyer_invoice(lg: Ledger, day: date, seller: str, gross: Decimal, memo: str) -> None: + vat = vat_of(gross) + net = gross - vat + lg.post(day, "41", None, "60.01", An(cp=seller), net, memo) + if vat > 0: + lg.post(day, "19", None, "60.01", An(cp=seller), vat, "НДС по закупке") + lg.post(day, "68", FNS, "19", None, vat, "НДС к вычету") + lg.post(day, "90.02", None, "41", None, net, "себестоимость") + + +def _interest(principal: Decimal) -> Decimal: + return q(principal * LOAN_RATE / 12) + + +def _lender_interest(lg: Ledger, day: date, borrower: str, amount: Decimal, memo: str) -> None: + lg.post(day, "76.09", An(cp=borrower), "91.01", An(cp=borrower, item="Проценты"), amount, memo) + + +def _borrower_interest(lg: Ledger, day: date, lender: str, amount: Decimal, memo: str) -> None: + lg.post(day, "91.02", An(cp=lender, item="Проценты"), "76.09", An(cp=lender), amount, memo) + + +# ---------- сальдо пары ---------- + +def _sum_cp(lg: Ledger, at: date, accounts: tuple[str, ...], cp: str) -> Decimal: + total = D(0) + for acct in accounts: + for (_, an), v in lg.balance(at, acct).items(): + if an is not None and an.cp == cp: + total += v + return total + + +def pair_balance(ledgers: dict[str, Ledger], pair: VgoPair, at: date) -> tuple[Decimal, Decimal]: + """Сальдо у `a` по `b` и у `b` по `a` по счетам пары: торговля 62.01/62.02 ↔ 60.01/60.02; + заём 58.03 + 76.09 ↔ 66.03 и 67.03 + 76.09 (оба субсчёта займов, у конкретной пары занят один).""" + if pair.kind == "trade": + acc_a, acc_b = ("62.01", "62.02"), ("60.01", "60.02") + else: + acc_a, acc_b = ("58.03", "76.09"), ("66.03", "67.03", "76.09") + return _sum_cp(ledgers[pair.a], at, acc_a, pair.b), _sum_cp(ledgers[pair.b], at, acc_b, pair.a) + + +# ---------- построитель ---------- + +class _Builder: + def __init__(self, cfg: Config, group: Group, ps: list[Period]): + self.cfg, self.group, self.ps = cfg, group, ps + self.pairs = plan_pairs(cfg, group) + self.trades = [p for p in self.pairs if p.kind == "trade"] + self.loans = [p for p in self.pairs if p.kind == "loan"] + self.vol: dict[tuple[str, str], Decimal] = {} # годовой объём (с НДС) пары + self.pay: dict[tuple[str, str], Decimal] = {} # доля оплаты в месяце счёта + self.principal: dict[str, Decimal] = {} + self.models: dict[str, CompanyModel] = {} + self.found: dict[int, dict[int, Mismatch]] = {} # индекс пары → год → расхождение + self.first_period: dict[int, Period] = {} + for p in ps: + self.first_period.setdefault(p.year, p) + self.plan_mm = self._plan_mismatches() + + # -- расчёт условий -- + def _init_terms(self, ledgers: dict[str, Ledger]) -> None: + self.models = {c: lg.meta["model"] for c, lg in ledgers.items()} + raw: dict[tuple[str, str], Decimal] = {} + for p in self.trades: + share = D(rng_for(self.cfg.seed, "trade-vol", p.a, p.b).randint(50, 200)) / 1000 + raw[(p.a, p.b)] = self.models[p.a].rev_year * share * (1 + D("0.2")) + self.pay[(p.a, p.b)] = D(rng_for(self.cfg.seed, "trade-pay", p.a, p.b).randint(50, 90)) / 100 + for a in sorted({p.a for p in self.trades}): + tot = sum((v for (aa, _), v in raw.items() if aa == a), D(0)) + cap = self.models[a].rev_year * (1 + D("0.2")) * SELLER_CAP + if tot > cap: + for key in raw: + if key[0] == a: + raw[key] = raw[key] * cap / tot + for b in sorted({p.b for p in self.trades}): + tot = sum((v for (_, bb), v in raw.items() if bb == b), D(0)) + cap = self.models[b].rev_year * (1 + D("0.2")) * BUYER_CAP + k = min(D(1), cap / tot) if tot > 0 else D(1) + for key in raw: + if key[1] == b: + raw[key] = raw[key] * k + self.vol = raw + for p in self.loans: + self.principal[p.b] = _loan_principal(self.cfg, self.models[p.b]) + + def _amount(self, p: VgoPair, year: int, month: int) -> Decimal: + noise = D(rng_for(self.cfg.seed, "trade-noise", p.a, p.b, year, month).randint(90, 110)) / 100 + return q(self.vol[(p.a, p.b)] / 12 * noise) + + # -- расхождения -- + def _plan_mismatches(self) -> dict[int, list[tuple[int, str, Decimal]]]: + """год → [(индекс пары, сторона, процент)] в порядке выбора; первые n//2 — малые (1–4 %), остальные — крупные.""" + share = self.cfg.vgo_mismatch_share + n = len(self.pairs) + out: dict[int, list[tuple[int, str, Decimal]]] = {} + if n == 0 or share <= 0: + return out + k = max(1, math.floor(share * n)) + k = min(k, n) + for year in self.first_period: + idx = rng_for(self.cfg.seed, "mismatch", year).sample(range(n), k) + small = k // 2 + rows = [] + for j, i in enumerate(idx): + rng = rng_for(self.cfg.seed, "mismatch-pct", year, i) + pct = D(rng.randint(105, 395)) / 100 if j < small else D(rng.randint(805, 2495)) / 100 + pair = self.pairs[i] + side = pair.a if rng_for(self.cfg.seed, "mismatch-side", year, i).random() < 0.5 else pair.b + rows.append((i, side, pct)) + out[year] = rows + return out + + def _post_extra(self, ledgers: dict[str, Ledger], p: VgoPair, side: str, amount: Decimal, day: date, memo: str) -> None: + if p.kind == "trade": + if side == p.a: + _seller_invoice(ledgers[p.a], day, p.b, amount, memo) + else: + _buyer_invoice(ledgers[p.b], day, p.a, amount, memo) + elif side == p.a: + ledgers[p.a].post(day, "58.03", An(cp=p.b), "51", None, amount, memo) + else: + ledgers[p.b].post(day, "51", None, loan_account(self.cfg, p.b), An(cp=p.a), amount, memo) + + # -- помесячные операции -- + def _trade_month(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: + first = self.ps[0].year + for p in self.trades: + cur = self._amount(p, year, month) + if cur <= 0: + continue + f = self.pay[(p.a, p.b)] + paid = q(cur * f) + prev_rest = D(0) + py, pm = (year - 1, 12) if month == 1 else (year, month - 1) + if py >= first: + pv = self._amount(p, py, pm) + prev_rest = pv - q(pv * f) + sa, bl = ledgers[p.a], ledgers[p.b] + _seller_invoice(sa, date(year, month, 10), p.b, cur, "реализация (ВГО)") + _buyer_invoice(bl, date(year, month, 10), p.a, cur, "закупка (ВГО)") + pay = paid + prev_rest + if pay > 0: + d20 = date(year, month, 20) + sa.post(d20, "51", None, "62.01", An(cp=p.b), pay, "поступление от покупателя") + bl.post(d20, "60.01", An(cp=p.a), "51", None, pay, "оплата поставщику") + + def _loan_month(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: + day = date(year, month, 28) + for p in self.loans: + amount = _interest(self.principal[p.b]) + if amount <= 0: + continue + la, bo = ledgers[p.a], ledgers[p.b] + _lender_interest(la, day, p.b, amount, "проценты по займу") + _borrower_interest(bo, day, p.a, amount, "проценты по займу") + la.post(day, "51", None, "76.09", An(cp=p.b), amount, "получение процентов") + bo.post(day, "76.09", An(cp=p.a), "51", None, amount, "уплата процентов") + + def _dividends(self, ledgers: dict[str, Ledger], year: int) -> None: + head = self.group.members[0] + for child in self.group.members[1:]: + total = q(self.models[child.code].rev_year * DIVIDEND_SHARE) + part = _head_part(child, total) + c, h = ledgers[child.code], ledgers[head.code] + d10, d20 = date(year, DIVIDEND_MONTH, 10), date(year, DIVIDEND_MONTH, 20) + if part > 0: + c.post(d10, "84", None, "76.03", An(cp=head.code), part, "объявлены дивиденды") + h.post(d10, "76.03", An(cp=child.code), "91.01", An(cp=child.code, item="Дивиденды"), part, "дивиденды дочерней") + c.post(d20, "76.03", An(cp=head.code), "51", None, part, "выплата дивидендов") + h.post(d20, "51", None, "76.03", An(cp=child.code), part, "получение дивидендов") + rest = total - part + if rest > 0 and child.minority is not None: + an = An(cp=child.minority.inn) + c.post(d10, "84", None, "76.03", an, rest, "объявлены дивиденды") + c.post(d20, "76.03", an, "51", None, rest, "выплата дивидендов") + + def _mismatches(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: + # создание: последний месяц первого периода года + fp = self.first_period.get(year) + if fp is not None and fp.end.month == month: + at = last_day(year, month) + for i, side, pct in self.plan_mm.get(year, []): + p = self.pairs[i] + ba, bb = pair_balance(ledgers, p, at) + base = min(abs(ba), abs(bb)) + amount = q(base * pct / 100) + if amount <= 0: + raise ValueError(f"пара {p.a}-{p.b} ({p.kind}): нулевое сальдо {base} на {at}, расхождения нет") + memo = "накладная в пути" if p.kind == "trade" else "транш в пути" + if p.kind == "loan" and side == p.a and ledgers[p.a].balance(at, "51").get(("51", None), D(0)) < 2 * amount: + side = p.b # у займодавца не хватает денег на транш: «в пути» у заёмщика (решение 32) + self._post_extra(ledgers, p, side, amount, date(year, month, 25), memo) + self.found.setdefault(i, {})[year] = Mismatch(side, year, fp.key, amount, pct, pct > THRESHOLD) + # снятие: 31 января следующего года, если январь внутри книги + if month == 1: + for i, by_year in sorted(self.found.items()): + mm = by_year.get(year - 1) + if mm is not None: + p = self.pairs[i] + other = p.b if mm.side == p.a else p.a + memo = "накладная дошла" if p.kind == "trade" else "транш получен" + self._post_extra(ledgers, p, other, mm.amount, date(year, 1, 31), memo) + + def extra(self, ledgers: dict[str, Ledger], year: int, month: int) -> None: + if not self.models: + self._init_terms(ledgers) + self._trade_month(ledgers, year, month) + self._loan_month(ledgers, year, month) + if month == DIVIDEND_MONTH: + self._dividends(ledgers, year) + self._mismatches(ledgers, year, month) + + def result_pairs(self) -> list[VgoPair]: + out = [] + for i, p in enumerate(self.pairs): + mms = tuple(v for _, v in sorted(self.found.get(i, {}).items())) + out.append(VgoPair(p.a, p.b, p.kind, mms[0] if mms else None, mms)) + return out + + +def build(cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan) -> tuple[dict[str, Ledger], list[VgoPair]]: + """Полные книги группы: внешняя экономика + операции внутри группы + расхождения ВГО; и итоговый список пар.""" + b = _Builder(cfg, group, ps) + ledgers = build_ledgers(cfg, group, ps, plan, extra=b.extra, opening_hook=_opening_hook(cfg, group)) + return ledgers, b.result_pairs() diff --git a/synthgen/ledger.py b/synthgen/ledger.py new file mode 100644 index 0000000..0e76da2 --- /dev/null +++ b/synthgen/ledger.py @@ -0,0 +1,119 @@ +"""Источник: synthetic-group/gen/ledger.py (#35). Книга проводок компании: двойная запись, сальдо и обороты как свёртка списка.""" + +import csv +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path + +CENT = Decimal("0.01") + +# Тип проводки (решение 35): закрывающие проводки помечены явно, свёртка ОФР не разбирает memo. +KIND_ORDINARY = "" # обычная операция +KIND_CLOSE_90 = "close90" # перенос сальдо 90.01..90.08 на 90.09 — в ОФР не входит +KIND_CLOSE_99 = "close99" # закрытие 90.09 и 91 на 99 — в ОФР не входит +KIND_CLOSE_COST = "close_cost" # закрытие 26/44 на 90.08/90.07 — это расходы периода, входит в ОФР + + +@dataclass(frozen=True, slots=True) +class An: + cp: str | None + contract: str | None = None + item: str | None = None + name_override: str | None = None + inn_override: str | None = None + + +@dataclass(frozen=True, slots=True) +class Posting: + day: date + dt: str + dt_an: An | None + ct: str + ct_an: An | None + amount: Decimal + memo: str + kind: str = KIND_ORDINARY + + +Key = tuple[str, An | None] + + +class Ledger: + def __init__(self, company: str): + self.company = company + self.postings: list[Posting] = [] + self._cache: dict = {} + # Индекс для быстрого сальдо «на конец»: накопленные итоги по (счёт, аналитика) и последний день проводок. + # Закрытие месяца спрашивает сальдо на дату не раньше всех проводок — тогда достаточно пройти по ключам, а не по проводкам. + self._tot: dict[Key, Decimal] = {} + self._maxday: date | None = None + self.meta: dict = {} # произвольные данные модели компании (используются economy) + + def post(self, day, dt, dt_an, ct, ct_an, amount, memo, kind: str = KIND_ORDINARY) -> None: + if amount <= 0 or amount != amount.quantize(CENT): + raise ValueError(f"сумма должна быть > 0 с двумя знаками: {amount}") + self.postings.append(Posting(day, dt, dt_an, ct, ct_an, amount, memo, kind)) + self._cache.clear() + kd, kc = (dt, dt_an), (ct, ct_an) + self._tot[kd] = self._tot.get(kd, Decimal(0)) + amount + self._tot[kc] = self._tot.get(kc, Decimal(0)) - amount + if self._maxday is None or day > self._maxday: + self._maxday = day + + @staticmethod + def _match(code: str, prefix: str | None) -> bool: + return prefix is None or code == prefix or code.startswith(prefix + ".") + + def balance(self, at: date, account_prefix: str | None = None) -> dict[Key, Decimal]: + ck = ("b", at, account_prefix) + if ck not in self._cache: + res: dict[Key, Decimal] = {} + if self._maxday is None or at >= self._maxday: + for key, v in self._tot.items(): + if self._match(key[0], account_prefix): + res[key] = v + else: + for p in self.postings: + if p.day > at: + continue + if self._match(p.dt, account_prefix): + res[(p.dt, p.dt_an)] = res.get((p.dt, p.dt_an), Decimal(0)) + p.amount + if self._match(p.ct, account_prefix): + res[(p.ct, p.ct_an)] = res.get((p.ct, p.ct_an), Decimal(0)) - p.amount + self._cache[ck] = {k: v for k, v in res.items() if v != 0} + return dict(self._cache[ck]) + + def turnover(self, start: date, end: date, account_prefix: str | None = None) -> dict[Key, tuple[Decimal, Decimal]]: + ck = ("t", start, end, account_prefix) + if ck not in self._cache: + res: dict[Key, list[Decimal]] = {} + for p in self.postings: + if not (start <= p.day <= end): + continue + if self._match(p.dt, account_prefix): + res.setdefault((p.dt, p.dt_an), [Decimal(0), Decimal(0)])[0] += p.amount + if self._match(p.ct, account_prefix): + res.setdefault((p.ct, p.ct_an), [Decimal(0), Decimal(0)])[1] += p.amount + self._cache[ck] = {k: (v[0], v[1]) for k, v in res.items()} + return dict(self._cache[ck]) + + def to_csv(self, path: Path) -> None: + with Path(path).open("w", encoding="utf-8", newline="") as f: + w = csv.writer(f, delimiter=";", lineterminator="\n") + w.writerow("day;dt;dt_cp;dt_contract;dt_item;ct;ct_cp;ct_contract;ct_item;amount;memo".split(";")) + for p in self.postings: + da, ca = p.dt_an, p.ct_an + w.writerow([ + p.day.isoformat(), p.dt, (da.cp or "") if da else "", (da.contract or "") if da else "", + (da.item or "") if da else "", p.ct, (ca.cp or "") if ca else "", (ca.contract or "") if ca else "", + (ca.item or "") if ca else "", f"{p.amount:.2f}", p.memo, + ]) + + +def opening(ledger: Ledger, day: date, account: str, an: An | None, amount: Decimal) -> None: + """Входящий остаток проводкой против вспомогательного счёта 000; amount со знаком (Дт > 0).""" + if amount > 0: + ledger.post(day, account, an, "000", None, amount, "входящий остаток") + elif amount < 0: + ledger.post(day, "000", None, account, an, -amount, "входящий остаток") diff --git a/synthgen/osv.py b/synthgen/osv.py new file mode 100644 index 0000000..e1827ea --- /dev/null +++ b/synthgen/osv.py @@ -0,0 +1,348 @@ +"""ОСВ по счёту в оформлении 1С 8.3 (спека §6.1): свёртка книги в строки и запись xlsx. + +`rows` свёртывает книгу компании в строки «счёт → субсчёт → контрагент» за период (нарастающим итогом с 1 января): +сальдо на начало/конец — по каждой строке одной стороной (Дт или Кт), обороты — обе; строка родителя равна сумме детей +по каждой из шести колонок. Строки без сальдо и оборотов не пишутся, как в 1С. `write` рисует лист по шаблону +`templates/osv_style.json` (собран из образца скриптом `build_osv_style.py`); у ОСВ с ИНН между блоком текста и блоком +сумм вставляется колонка ИНН, остальная сетка сдвигается на одну колонку (решение 3). + +Производительность (решение 33): для свёртки по периодам книга один раз индексируется по (счёт, контрагент) с +накопленными итогами по дням; `Ledger.balance/turnover` на каждый период проходили бы по всем проводкам. +""" + +import json +from bisect import bisect_right +from dataclasses import dataclass +from datetime import timedelta +from decimal import Decimal +from functools import cache +from pathlib import Path + +import openpyxl +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.properties import Outline + +from synthgen import xlsx +from synthgen.counterparties import OSV_ACCOUNTS +from synthgen.group import Group, Member, variant_spelling +from synthgen.ledger import Ledger +from synthgen.periods import Period + +ZERO = Decimal(0) +STYLE_PATH = Path(__file__).with_name("templates") / "osv_style.json" +SHEET = "Лист_1" +AMOUNT_FORMAT = "#,##0.00" +Six = tuple[Decimal, Decimal, Decimal, Decimal, Decimal, Decimal] + + +@dataclass(frozen=True) +class OsvRow: + level: int # 0 счёт, 1 субсчёт, 2 контрагент (глубже — вложенные субсчета) + label: str # код счёта/субсчёта или имя контрагента + inn: str | None + od: Decimal # сальдо на начало, Дт + oc: Decimal # сальдо на начало, Кт + td: Decimal # обороты, Дт + tc: Decimal # обороты, Кт + cd: Decimal # сальдо на конец, Дт + cc: Decimal # сальдо на конец, Кт + is_cp: bool = False # строка контрагента + cp: str | None = None # код аналитики контрагента в книге (для сверки с Ledger) + + @property + def six(self) -> Six: + return (self.od, self.oc, self.td, self.tc, self.cd, self.cc) + + +def file_name(account: str, period: Period) -> str: + # метка периода заканчивается точкой после «г», она же — точка перед расширением + return f"сч {account}_{period.label_1c[:-1]}.xlsx" + + +# --- индекс книги ----------------------------------------------------------------------------------------------- + + +class _Series: + """Накопленные по дням итоги одного ключа (счёт, контрагент): сальдо (Дт − Кт), обороты Дт и Кт.""" + + __slots__ = ("days", "net", "dt", "ct") + + def __init__(self, items: list[tuple[int, Decimal, Decimal]]): + items.sort(key=lambda x: x[0]) + self.days = [d for d, _, _ in items] + self.net, self.dt, self.ct = [], [], [] + n = a = b = ZERO + for _, debit, credit in items: + a += debit + b += credit + n = a - b + self.net.append(n) + self.dt.append(a) + self.ct.append(b) + + def _at(self, arr: list[Decimal], ordinal: int) -> Decimal: + i = bisect_right(self.days, ordinal) + return arr[i - 1] if i else ZERO + + def amounts(self, start_ord: int, end_ord: int) -> tuple[Decimal, Decimal, Decimal, Decimal]: + """(сальдо на начало, обороты Дт, обороты Кт, сальдо на конец) за [start, end].""" + before = start_ord - 1 + return ( + self._at(self.net, before), + self._at(self.dt, end_ord) - self._at(self.dt, before), + self._at(self.ct, end_ord) - self._at(self.ct, before), + self._at(self.net, end_ord), + ) + + +def _index(ledger: Ledger) -> dict[str, dict[tuple[str, str | None], _Series]]: + """Корень счёта → {(код счёта, контрагент или None): серия}; пересобирается при изменении числа проводок.""" + cached = ledger.meta.get("_osv_index") + if cached is not None and cached[0] == len(ledger.postings): + return cached[1] + raw: dict[str, dict[tuple[str, str | None], list[tuple[int, Decimal, Decimal]]]] = {} + for p in ledger.postings: + ordinal = p.day.toordinal() + for code, an, debit, credit in ((p.dt, p.dt_an, p.amount, ZERO), (p.ct, p.ct_an, ZERO, p.amount)): + root = code.split(".")[0] + if root in OSV_ACCOUNTS: + raw.setdefault(root, {}).setdefault((code, an.cp if an is not None else None), []).append( + (ordinal, debit, credit) + ) + index = {root: {key: _Series(items) for key, items in by_key.items()} for root, by_key in raw.items()} + ledger.meta["_osv_index"] = (len(ledger.postings), index) + return index + + +# --- имена контрагентов ----------------------------------------------------------------------------------------- + +_DIRECTORY: dict[int, tuple[Group, dict[str, tuple[str, str | None]]]] = {} + + +def _directory(group: Group) -> dict[str, tuple[str, str | None]]: + """Код аналитики контрагента → (имя в 1С, ИНН): внешние и банки по ИНН, члены группы по коду, миноритарии.""" + cached = _DIRECTORY.get(id(group)) + if cached is not None and cached[0] is group: + return cached[1] + out: dict[str, tuple[str, str | None]] = {} + for org in [*group.externals, *group.banks, *(m.minority for m in group.members if m.minority)]: + out[org.inn] = (org.name, org.inn) + for m in group.members: + out[m.code] = (m.name_1c, m.inn) + _DIRECTORY[id(group)] = (group, out) + return out + + +def _identity(cp: str, member: Member, group: Group) -> tuple[str, str | None]: + entry = _directory(group).get(cp) + if entry is None: + raise ValueError(f"{member.code}: контрагент {cp!r} неизвестен группе (в ОСВ не выгружается)") + name, inn = entry + if member.code in group.no_inn: + variant_code = group.variant_name.get(member.code) + if variant_code == cp: + name = variant_spelling(next(m for m in group.members if m.code == cp)) + return name, None + return name, inn + + +# --- свёртка ---------------------------------------------------------------------------------------------------- + + +def _natural_key(code: str) -> list[tuple[int, int, str]]: + return [(0, int(part), "") if part.isdigit() else (1, 0, part) for part in code.split(".")] + + +def _sided(net: Decimal) -> tuple[Decimal, Decimal]: + return (net, ZERO) if net > 0 else (ZERO, -net) + + +def _add(a: Six, b: Six) -> Six: + return tuple(x + y for x, y in zip(a, b, strict=True)) # type: ignore[return-value] + + +def rows(ledger: Ledger, account: str, period: Period, member: Member, group: Group) -> list[OsvRow]: + """Строки ОСВ по счёту в порядке вывода (счёт, субсчета по возрастанию кода, контрагенты по имени); без «Итого».""" + start, end = period.start.toordinal(), period.end.toordinal() + leaves: dict[str, dict[str | None, Six]] = {} + for (code, cp), series in _index(ledger).get(account, {}).items(): + opening, td, tc, closing = series.amounts(start, end) + od, oc = _sided(opening) + cd, cc = _sided(closing) + six: Six = (od, oc, td, tc, cd, cc) + if any(six): + leaves.setdefault(code, {})[cp] = six + + children: dict[str, set[str]] = {} + for code in leaves: + parts = code.split(".") + for i in range(len(parts)): + prefix = ".".join(parts[: i + 1]) + children.setdefault(prefix, set()) + if i: + children[".".join(parts[:i])].add(prefix) + for code, subs in children.items(): + if subs and code in leaves: + raise ValueError(f"{member.code} {account}: счёт {code} и с проводками, и с субсчетами") + + def leaf_rows(code: str, level: int) -> tuple[list[OsvRow], Six]: + by_cp = leaves[code] + if None in by_cp: + if len(by_cp) > 1: + raise ValueError(f"{member.code} {code}: строки без контрагента и с контрагентом вместе") + return [], by_cp[None] + named = sorted(((*_identity(cp, member, group), cp) for cp in by_cp if cp is not None), key=lambda x: (x[0], x[2])) + out: list[OsvRow] = [] + total: Six = (ZERO,) * 6 + for name, inn, cp in named: + six = by_cp[cp] + total = _add(total, six) + out.append(OsvRow(level, name, inn, *six, is_cp=True, cp=cp)) + return out, total + + def build(code: str, level: int) -> tuple[list[OsvRow], Six]: + if code in leaves: + body, total = leaf_rows(code, level + 1) + else: + body, total = [], (ZERO,) * 6 + for sub in sorted(children[code], key=_natural_key): + sub_rows, sub_total = build(sub, level + 1) + body += sub_rows + total = _add(total, sub_total) + return [OsvRow(level, code, None, *total), *body], total + + if account not in children: + return [] + return build(account, 0)[0] + + +def total_of(data: list[OsvRow]) -> Six: + """«Итого» листа: сумма строк счетов уровня 0 (в листе она одна; у пустой ОСВ — нули).""" + result: Six = (ZERO,) * 6 + for r in data: + if r.level == 0: + result = _add(result, r.six) + return result + + +# --- лист ------------------------------------------------------------------------------------------------------- + + +@cache +def _style() -> dict: + return json.loads(STYLE_PATH.read_text(encoding="utf-8")) + + +def _side(spec: dict | None) -> Side: + if not spec: + return Side() + return Side(style=spec["style"], color=spec["color"]) if spec["color"] else Side(style=spec["style"]) + + +class _Styles: + """Готовые объекты openpyxl по шаблону: (тип строки, колонка шаблона) → (шрифт, заливка, рамка, выравнивание, формат).""" + + def __init__(self, tpl: dict): + self._tpl = tpl + self._cache: dict[tuple[str, int, int | None], tuple] = {} + + def get(self, kind: str, col: int, indent: int | None = None) -> tuple: + key = (kind, col, indent) + hit = self._cache.get(key) + if hit is None: + spec = self._spec(kind)["cells"][col - 1] + f, a = spec["font"], spec["align"] + font = Font(name=f["name"], sz=f["sz"], b=f["b"], i=f["i"], color=f["color"]) + fill = PatternFill("solid", fgColor=spec["fill"]) if spec["fill"] else PatternFill() + bd = spec["border"] + border = Border(left=_side(bd["left"]), right=_side(bd["right"]), top=_side(bd["top"]), bottom=_side(bd["bottom"])) + align = Alignment(horizontal=a["h"], vertical=a["v"], wrap_text=a["wrap"], indent=a["indent"] if indent is None else indent) + hit = self._cache[key] = (font, fill, border, align, spec["nf"]) + return hit + + def _spec(self, kind: str) -> dict: + if kind.startswith("header"): + return self._tpl["header"][int(kind[6:]) - 1] + return self._tpl["total"] if kind == "total" else self._tpl["rows"][kind] + + +def _apply(cell, style: tuple) -> None: + cell.font, cell.fill, cell.border, cell.alignment, cell.number_format = style + + +def write(path: Path, member: Member, account: str, period: Period, data: list[OsvRow], with_inn: bool) -> None: + """Пишет лист ОСВ по шаблону 1С; `with_inn` вставляет колонку ИНН после блока текста (E).""" + tpl = _style() + st = _Styles(tpl) + text_end = tpl["text_span"][1] # колонка D + inn_col = text_end + 1 + shift = 1 if with_inn else 0 + + def col(c: int) -> int: # колонка шаблона → колонка листа + return c + shift if c > text_end else c + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = SHEET + ws.sheet_properties.outlinePr = Outline(summaryBelow=bool(tpl["summary_below"]), summaryRight=True) + ncols = tpl["ncols"] + shift + for c, width in tpl["col_widths"].items(): + ws.column_dimensions[get_column_letter(col(int(c)))].width = width + if with_inn: + ws.column_dimensions[get_column_letter(inn_col)].width = tpl["inn_width"] + + def style_row(r: int, kind: str, indent: int | None = None) -> None: + for c in range(1, ncols + 1): + tc = c if c <= text_end else (text_end if c == inn_col and with_inn else c - shift) + _apply(ws.cell(r, c), st.get(kind, tc, indent if tc == 1 else None)) + + placeholders = {"{org}": member.name, "{title}": f"Оборотно-сальдовая ведомость по счету {account} за {period.label_1c}"} + for i, row in enumerate(tpl["header"], start=1): + style_row(i, f"header{i}") + ws.row_dimensions[i].height = row["height"] + ws.row_dimensions[i].outlineLevel = row["outline"] + for c, value in row["values"].items(): + ws.cell(i, col(int(c)), placeholders.get(value, value)) + if with_inn: + # шапка колонки ИНН оформлена как соседняя шапка «Сальдо на начало периода» (заливка, рамки, Arial 9) + first_sum = tpl["sum_spans"][0][0] + for i in (9, 10, 11): + _apply(ws.cell(i, inn_col), st.get(f"header{i}", first_sum)) + ws.cell(10, inn_col, "ИНН") + for r1, c1, r2, c2 in tpl["header_merges"]: + ws.merge_cells(start_row=r1, start_column=col(c1), end_row=r2, end_column=col(c2)) + if with_inn: + ws.merge_cells(start_row=10, start_column=inn_col, end_row=11, end_column=inn_col) + + spans = [(col(a), col(b)) for a, b in tpl["sum_spans"]] + + def put_sums(r: int, six: Six) -> None: + for (a, b), value in zip(spans, six, strict=True): + if value != 0: + # формат задаём явно: в строке-образце сальдо бывает пустым, и его ячейка несёт «Общий» формат + ws.cell(r, a, float(value)).number_format = AMOUNT_FORMAT + if b > a: + ws.merge_cells(start_row=r, start_column=a, end_row=r, end_column=b) + + def put_label(r: int, text: str) -> None: + ws.cell(r, 1, text) + ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=text_end) + + r = len(tpl["header"]) + 1 + for item in data: + kind = "cp" if item.is_cp else ("account" if item.level == 0 else "sub") + style_row(r, kind, item.level) + spec = tpl["rows"][kind] + ws.row_dimensions[r].height = spec["height"] + ws.row_dimensions[r].outlineLevel = min(item.level, 7) + put_label(r, item.label) + if with_inn and item.inn: + ws.cell(r, inn_col, item.inn) + put_sums(r, item.six) + r += 1 + style_row(r, "total") + ws.row_dimensions[r].height = tpl["total"]["height"] + put_label(r, "Итого") + put_sums(r, total_of(data)) + xlsx.save_deterministic(wb, path) diff --git a/synthgen/periods.py b/synthgen/periods.py new file mode 100644 index 0000000..fca4bf6 --- /dev/null +++ b/synthgen/periods.py @@ -0,0 +1,46 @@ +"""Отчётные периоды (спека §5.1): кварталы подряд, каждый — нарастающим итогом с начала года, как выгружает 1С.""" + +from dataclasses import dataclass +from datetime import date + +_LABELS = {1: "1 квартал {y} г.", 2: "1 полугодие {y} г.", 3: "9 месяцев {y} г.", 4: "{y} г."} +_END = {1: (3, 31), 2: (6, 30), 3: (9, 30), 4: (12, 31)} + + +@dataclass(frozen=True) +class Period: + key: str # 2025-Q1, 2025-Q2, 2025-Q3, 2025-FY + year: int + quarter: int + start: date # всегда 1 января (нарастающий итог) + end: date + label_1c: str + + +def _make(year: int, quarter: int) -> Period: + month, day = _END[quarter] + key = f"{year}-FY" if quarter == 4 else f"{year}-Q{quarter}" + return Period(key, year, quarter, date(year, 1, 1), date(year, month, day), _LABELS[quarter].format(y=year)) + + +def build_periods(start: str, n: int) -> list[Period]: + """`n` периодов подряд начиная с `start` (`ГГГГ-Qn`); четвёртый квартал — годовой период `ГГГГ-FY`.""" + year_s, q_s = start.split("-Q") + year, quarter = int(year_s), int(q_s) + out: list[Period] = [] + for _ in range(n): + out.append(_make(year, quarter)) + quarter += 1 + if quarter == 5: + year, quarter = year + 1, 1 + return out + + +def book_start(ps: list[Period]) -> date: + """Дата входящих остатков: 31 декабря года перед первым периодом.""" + return date(ps[0].year - 1, 12, 31) + + +def book_end(ps: list[Period]) -> date: + """Конец книги — конец последнего периода.""" + return ps[-1].end diff --git a/synthgen/report.py b/synthgen/report.py new file mode 100644 index 0000000..116e8a1 --- /dev/null +++ b/synthgen/report.py @@ -0,0 +1,141 @@ +"""README прогона (спека §7): журнал генератора — настройки, состав группы, пары ВГО с расхождениями, число контрагентов. + +Это не оракул: ожидаемых итогов консолидации здесь нет, только то, что генератор заложил в данные. +""" + +from dataclasses import replace +from decimal import Decimal +from typing import TYPE_CHECKING + +from synthgen.config import Config, dump_config +from synthgen.counterparties import WEIGHTS, CounterpartyPlan +from synthgen.group import Group, variant_spelling +from synthgen.intragroup import VgoPair + +if TYPE_CHECKING: + from synthgen.generate import Manifest + +KIND = {"trade": "торговля", "loan": "заём"} + + +def _money(x: Decimal) -> str: + return f"{x:,.2f}".replace(",", " ") + + +def _settings(cfg: Config) -> list[str]: + return ["## Настройки прогона", "", "```yaml", dump_config(replace(cfg, out=None)).rstrip(), "```", ""] + + +def _members(group: Group) -> list[str]: + lines = [ + "## Состав группы", "", f"{group.name} (iD ГК {group.gk_id}); все компании входят в ГСК.", "", + "| Код | Название | ИНН | Доля головной | Миноритарий |", "|---|---|---|---|---|", + ] # fmt: skip + for m in group.members: + share = "головная" if m.head_share is None else f"{m.head_share * 100:.0f} %" + minority = f"{m.minority.name} (ИНН {m.minority.inn})" if m.minority is not None else "—" + lines.append(f"| {m.code} | {m.name} | {m.inn} | {share} | {minority} |") + return [*lines, ""] + + +def _no_inn(group: Group) -> list[str]: + lines = ["## Компании без ИНН и вариантное имя", ""] + if not group.no_inn: + return [*lines, "Все ОСВ выгружены с ИНН контрагентов; вариантных имён нет.", ""] + by_code = {m.code: m for m in group.members} + for code in sorted(group.no_inn): + other = by_code[group.variant_name[code]] + lines.append( + f"- {code}: ОСВ без колонки ИНН; контрагент {other.code} ({other.name}) записан в ней вариантным " + f"написанием «{variant_spelling(other)}» (корень, префикс и форма без кавычек). Ядро " + "такое имя автоматически не сопоставит (имя нормализуется с сохранением порядка слов): нужна ручная привязка алиаса." + ) + return [*lines, ""] + + +def _pairs(group: Group, pairs: list[VgoPair]) -> list[str]: + lines = ["## Пары ВГО", ""] + if not pairs: + return [*lines, "Пар ВГО нет: расхождений сторон тоже нет.", ""] + lines.append(f"Пар: {len(pairs)} (торговля: {sum(p.kind == 'trade' for p in pairs)}, займы: {sum(p.kind == 'loan' for p in pairs)}).") + mism = [(p, m) for p in pairs for m in p.mismatches] + if not mism: + return [*lines, "", "Расхождений сторон в этом прогоне нет.", ""] + lines += [ + "", "Расхождения: сумма — разница сторон, % — от меньшей стороны сальдо пары на конец периода; порог 5 %.", "", + "| Пара (продавец/займодавец → покупатель/заёмщик) | Вид | Период | Проводка у | Сумма, руб. | % | Порог | Ожидание в ядре |", + "|---|---|---|---|---|---|---|---|", + ] # fmt: skip + for p, m in mism: + verdict = "выше порога 5 %" if m.above_threshold else "ниже порога 5 %" + 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, ""] + + +def _counts(plan: CounterpartyPlan, group: Group) -> list[str]: + lines = [ + "## Внешние контрагенты по файлам ОСВ", "", + "| Счёт | Мин | Среднее | Макс |", "|---|---|---|---|", + ] # fmt: skip + for acct in WEIGHTS: + counts = [len(plan.by_file[(m.code, acct)]) for m in group.members] + lines.append(f"| {acct} | {min(counts)} | {sum(counts) / len(counts):.1f} | {max(counts)} |") + lines.append("") + if plan.capped: + lines += ["Среднее ограничено словарём: потолок (размер словаря без банков и компаний группы) срезал выборку.", ""] + return lines + + +NOTES = """\ +## Что нужно знать при чтении результатов + +Набор не оракул: ожидаемых итогов консолидации здесь нет, 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 остаётся по одному служебному внешнему контрагенту, чтобы строки + расчётов с компаниями группы и внешние обороты не смешивались на одном субсчёте. +""" + + +def render(cfg: Config, group: Group, pairs: list[VgoPair], plan: CounterpartyPlan, manifest: "Manifest") -> str: + head = [ + f"# Синтетический набор {group.name}", "", + f"Сгенерирован `python -m synthgen` (seed {cfg.seed}). Файлов: {len(manifest.files)}. Данные вымышленные; " + "это журнал генератора, а не оракул.", "", + ] # fmt: skip + parts = [head, _settings(cfg), _members(group), _no_inn(group), _pairs(group, pairs), _counts(plan, group), NOTES.splitlines()] + return "\n".join(line for part in parts for line in part).rstrip() + "\n" diff --git a/synthgen/rng.py b/synthgen/rng.py new file mode 100644 index 0000000..d93d4b5 --- /dev/null +++ b/synthgen/rng.py @@ -0,0 +1,19 @@ +"""Детерминированная случайность: одно зерно на каждую подсистему. + +Любая случайность в генераторе — только через `rng_for(seed, *keys)`; модульный `random` запрещён. +Ключи должны иметь стабильный `repr` (строки, числа, даты, кортежи из них). +""" + +import hashlib +import random + + +def rng_for(seed: int, *keys: object) -> random.Random: + """Генератор, зерно = первые 8 байт SHA-256 от repr((seed, *keys)) (порядок байт — big-endian).""" + digest = hashlib.sha256(repr((seed, *keys)).encode("utf-8")).digest() + return random.Random(int.from_bytes(digest[:8], "big")) + + +def stable_hash(text: str, modulo: int) -> int: + """Стабильный хеш строки (SHA-256 → целое → остаток); в отличие от hash() не зависит от PYTHONHASHSEED.""" + return int.from_bytes(hashlib.sha256(text.encode("utf-8")).digest(), "big") % modulo diff --git a/synthgen/templates/__init__.py b/synthgen/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/synthgen/templates/bb_1c.xlsx b/synthgen/templates/bb_1c.xlsx new file mode 100644 index 0000000..7b32019 Binary files /dev/null and b/synthgen/templates/bb_1c.xlsx differ diff --git a/synthgen/templates/build_forms_templates.py b/synthgen/templates/build_forms_templates.py new file mode 100644 index 0000000..dd65d5c --- /dev/null +++ b/synthgen/templates/build_forms_templates.py @@ -0,0 +1,82 @@ +"""Сборка шаблонов ББ и ОФР в оформлении 1С (`bb_1c.xlsx`, `ofr_1c.xlsx`) из реальных образцов — один раз, результат коммитится. + +Запуск из корня worktree: + uv run -q --with openpyxl==3.1.5 python -m synthgen.templates.build_forms_templates [каталог образцов] + +Книга образца копируется целиком (шрифты Times New Roman, объединения, рамки, высоты строк, форматы, блок «КОДЫ», +разрывы страниц, неизменяемые тексты форм 66н). В шаблоне очищено всё, что принадлежит образцу или считается генератором: +- все формулы (итоги, заголовки второй страницы, служебные расчёты) заменены пустыми значениями; +- числа в колонках значений строк с кодами, наименование организации, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, вид деятельности, + адрес, дата формы, периодные подписи колонок и заголовка, ФИО подписантов; +- ячейки правее последней колонки формы (служебные расчёты автора образца) — значения и оформление. +Генератор (`synthgen.forms_xlsx`) пишет свои значения по кодам строк. +""" + +import sys +from pathlib import Path + +import openpyxl +from openpyxl.cell.cell import MergedCell +from openpyxl.styles import Alignment, Border, Font, PatternFill + +from synthgen import xlsx + +SAMPLES = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/statements-xlsx/mezheninovskaya/2025-Q3") +HERE = Path(__file__).parent +# (файл образца, имя шаблона, последняя колонка формы, колонка кодов, ячейки реквизитов и подписей для очистки) +BUILD = { + "BALANCE": ( + "1_Форма1_бух_09.xlsx", "bb_1c.xlsx", 20, 11, + ("B3", "G7", "R6", "S6", "T6", "R7", "R8", "G9", "R9", "G10", "R10", "S10", "G13", "M23", "Q23", "S23", "M48", "Q48", "S48", + "Q80", "Q83"), + ), + "INCOME": ( + "1_Форма2_бух_09.xlsx", "ofr_1c.xlsx", 18, 13, + ("B3", "G7", "P6", "Q6", "R6", "P7", "P8", "G9", "P9", "G10", "P10", "Q10", "O14", "Q14", "O36", "Q36", "O49", "O52"), + ), +} # fmt: skip + + +def _is_code(value) -> bool: + return isinstance(value, str) and value.isdigit() and len(value) == 4 + + +def build(kind: str, samples: Path = SAMPLES, out_dir: Path = HERE) -> Path: + sample, name, last_col, code_col, clear = BUILD[kind] + wb = openpyxl.load_workbook(samples / sample) + ws = wb.active + value_cols: set[int] = set() + for row in ws.iter_rows(): + for cell in row: # колонки значений: ячейки номеров колонок (строка под «Коды») правее колонки кодов + if cell.value == "Коды" and cell.column == code_col: + value_cols |= {c.column for c in ws[cell.row + 1] if c.value is not None and c.column > code_col} + code_rows = [r for r in range(1, ws.max_row + 1) if _is_code(ws.cell(r, code_col).value)] + for coord in clear: + ws[coord].value = None + for r in code_rows: + for c in value_cols: + cell = ws.cell(r, c) + if not isinstance(cell, MergedCell): + cell.value = None + for row in ws.iter_rows(): + for cell in row: + if isinstance(cell.value, str) and cell.value.startswith("="): + cell.value = None + if cell.column > last_col and not isinstance(cell, MergedCell): + cell.value = None + cell.font, cell.fill, cell.border, cell.alignment = Font(), PatternFill(), Border(), Alignment() + cell.number_format = "General" + wb.properties.title = None + path = out_dir / name + xlsx.save_deterministic(wb, path) + return path + + +def main(argv: list[str]) -> None: + samples = Path(argv[1]) if len(argv) > 1 else SAMPLES + for kind in BUILD: + print(build(kind, samples)) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/synthgen/templates/build_osv_style.py b/synthgen/templates/build_osv_style.py new file mode 100644 index 0000000..e157ed3 --- /dev/null +++ b/synthgen/templates/build_osv_style.py @@ -0,0 +1,90 @@ +"""Сборка шаблона оформления ОСВ 1С (`osv_style.json`) из реального образца — один раз, результат коммитится. + +Запуск из корня worktree: + uv run -q --with openpyxl==3.1.5 python -m synthgen.templates.build_osv_style [путь к образцу] + +В JSON попадают только оформление и неизменяемые тексты шапки (строки 1–11): ширины колонок, высоты и уровни +группировки строк, шрифты, заливки, рамки, выравнивание, объединения. Название организации и период в шапке +заменены плейсхолдерами `{org}` и `{title}`; ни одно значение данных образца (контрагенты, суммы) в шаблон не попадает. +Сетка — «блок текста A:D + блок сумм E:Q»; колонка ИНН вставляется между ними при записи (`synthgen.osv`). +""" + +import json +import sys +from pathlib import Path + +import openpyxl + +SAMPLE = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/osv/rb/2024-Q1/сч 60_ 1 кв 2024 г.xlsx") +OUT = Path(__file__).with_name("osv_style.json") +NCOLS = 17 # A…Q: дальше в образце только служебные хвосты +HEADER_ROWS = 11 +ROW_TYPES = {"account": 12, "sub": 13, "cp": 14} # образцы строк данных; «Итого» ищется по тексту +PLACEHOLDERS = {(1, 1): "{org}", (2, 1): "{title}"} + + +def _side(side) -> dict | None: + if not side or not side.style: + return None + return {"style": side.style, "color": side.color.rgb if side.color is not None and isinstance(side.color.rgb, str) else None} + + +def _style(cell) -> dict: + font, fill, al, bd = cell.font, cell.fill, cell.alignment, cell.border + color = font.color.rgb if font.color is not None and isinstance(font.color.rgb, str) else None + return { + "font": {"name": font.name, "sz": font.sz, "b": bool(font.b), "i": bool(font.i), "color": color}, + "fill": fill.fgColor.rgb if fill.fill_type == "solid" else None, + "border": {k: _side(getattr(bd, k)) for k in ("left", "right", "top", "bottom")}, + "align": {"h": al.horizontal, "v": al.vertical, "wrap": bool(al.wrap_text), "indent": al.indent or 0}, + "nf": cell.number_format, + } + + +def _row(ws, r: int) -> dict: + dim = ws.row_dimensions[r] + return { + "height": dim.height, + "outline": dim.outlineLevel or 0, + "cells": [_style(ws.cell(r, c)) for c in range(1, NCOLS + 1)], + } + + +def build(sample: Path = SAMPLE, out: Path = OUT) -> dict: + ws = openpyxl.load_workbook(sample).active + total_row = next(r for r in range(ws.max_row, 0, -1) if ws.cell(r, 1).value == "Итого") + spans = sorted( + (m.min_col, m.max_col) for m in ws.merged_cells.ranges if m.min_row == 10 and m.max_row == 11 and m.min_col > 4 + ) + header = [] + for r in range(1, HEADER_ROWS + 1): + row = _row(ws, r) + row["values"] = { + str(c): PLACEHOLDERS.get((r, c), ws.cell(r, c).value) + for c in range(1, NCOLS + 1) + if ws.cell(r, c).value is not None + } + header.append(row) + style = { + "source": "образец 1С 8.3 (БП 3.0): ОСВ по счёту, лист Лист_1", + "ncols": NCOLS, + "text_span": [1, 4], # блок «Счет / Контрагенты»: A:D + "sum_spans": [list(s) for s in spans], # шесть блоков сумм: сальдо нач. Дт/Кт, обороты Дт/Кт, сальдо кон. Дт/Кт + "col_widths": { + str(c): ws.column_dimensions[openpyxl.utils.get_column_letter(c)].width for c in range(1, NCOLS + 1) + }, + "inn_width": 11.0, + "header": header, + "header_merges": sorted([m.min_row, m.min_col, m.max_row, m.max_col] for m in ws.merged_cells.ranges if m.min_row <= HEADER_ROWS), + "rows": {name: _row(ws, r) for name, r in ROW_TYPES.items()}, + "total": _row(ws, total_row), + "default_row_height": ws.sheet_format.defaultRowHeight, + "summary_below": bool(ws.sheet_properties.outlinePr.summaryBelow), + "grid_lines": ws.sheet_view.showGridLines is not False, + } + out.write_text(json.dumps(style, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8") + return style + + +if __name__ == "__main__": + build(Path(sys.argv[1]) if len(sys.argv) > 1 else SAMPLE) diff --git a/synthgen/templates/ofr_1c.xlsx b/synthgen/templates/ofr_1c.xlsx new file mode 100644 index 0000000..6809e31 Binary files /dev/null and b/synthgen/templates/ofr_1c.xlsx differ diff --git a/synthgen/templates/osv_style.json b/synthgen/templates/osv_style.json new file mode 100644 index 0000000..b40871a --- /dev/null +++ b/synthgen/templates/osv_style.json @@ -0,0 +1,7107 @@ +{ + "col_widths": { + "1": 10.42578125, + "10": 13.42578125, + "11": 5.140625, + "12": 5.28515625, + "13": 10.42578125, + "14": 2.85546875, + "15": 7.7109375, + "16": 11.0, + "17": 18.7109375, + "2": 4.140625, + "3": 1.28515625, + "4": 19.140625, + "5": 3.28515625, + "6": 7.85546875, + "7": 7.42578125, + "8": 1.85546875, + "9": 16.85546875 + }, + "default_row_height": 11.4, + "grid_lines": true, + "header": [ + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": true, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": true, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": true, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 13.05, + "outline": 0, + "values": { + "1": "{org}" + } + }, + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": true, + "color": null, + "i": false, + "name": "Arial", + "sz": 12.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": true, + "color": null, + "i": false, + "name": "Arial", + "sz": 12.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": true, + "color": null, + "i": false, + "name": "Arial", + "sz": 12.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 16.05, + "outline": 0, + "values": { + "1": "{title}" + } + }, + { + "cells": [ + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 1.95, + "outline": 0, + "values": {} + }, + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 10.95, + "outline": 0, + "values": { + "1": "Выводимые данные: БУ (данные бухгалтерского учета)" + } + }, + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": null, + "wrap": true + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 1.95, + "outline": 0, + "values": {} + }, + { + "cells": [ + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 10.05, + "outline": 0, + "values": {} + }, + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 13.05, + "outline": 1, + "values": { + "1": "Параметры:", + "3": "Валюта БУ, НУ: RUB" + } + }, + { + "cells": [ + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 10.05, + "outline": 0, + "values": {} + }, + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 13.05, + "outline": 0, + "values": { + "1": "Счет", + "10": "Обороты за период", + "15": "Сальдо на конец периода", + "5": "Сальдо на начало периода" + } + }, + { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": null, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "center", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 9.0 + }, + "nf": "General" + } + ], + "height": 10.95, + "outline": 0, + "values": { + "1": "Контрагенты", + "10": "Дебет", + "12": "Кредит", + "15": "Дебет", + "17": "Кредит", + "5": "Дебет", + "8": "Кредит" + } + }, + { + "cells": [ + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": null + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 10.95, + "outline": 0, + "values": {} + } + ], + "header_merges": [ + [ + 4, + 1, + 4, + 15 + ], + [ + 9, + 1, + 9, + 4 + ], + [ + 9, + 5, + 9, + 9 + ], + [ + 9, + 10, + 9, + 14 + ], + [ + 9, + 15, + 9, + 17 + ], + [ + 10, + 1, + 11, + 4 + ], + [ + 10, + 5, + 11, + 7 + ], + [ + 10, + 8, + 11, + 9 + ], + [ + 10, + 10, + 11, + 11 + ], + [ + 10, + 12, + 11, + 14 + ], + [ + 10, + 15, + 11, + 16 + ], + [ + 10, + 17, + 11, + 17 + ] + ], + "inn_width": 11.0, + "ncols": 17, + "rows": { + "account": { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + } + ], + "height": 10.95, + "outline": 0 + }, + "cp": { + "cells": [ + { + "align": { + "h": "left", + "indent": 2.0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + } + ], + "height": 10.95, + "outline": 2 + }, + "sub": { + "cells": [ + { + "align": { + "h": "left", + "indent": 1.0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF8F2D8", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "#,##0.00" + } + ], + "height": 10.95, + "outline": 1 + } + }, + "source": "образец 1С 8.3 (БП 3.0): ОСВ по счёту, лист Лист_1", + "sum_spans": [ + [ + 5, + 7 + ], + [ + 8, + 9 + ], + [ + 10, + 11 + ], + [ + 12, + 14 + ], + [ + 15, + 16 + ], + [ + 17, + 17 + ] + ], + "summary_below": false, + "text_span": [ + 1, + 4 + ], + "total": { + "cells": [ + { + "align": { + "h": "left", + "indent": 0, + "v": "top", + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "#,##0.00" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": null, + "indent": 0, + "v": null, + "wrap": false + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": null, + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 8.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": null, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": null, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "General" + }, + { + "align": { + "h": "right", + "indent": 0, + "v": "top", + "wrap": true + }, + "border": { + "bottom": { + "color": "FFCCC085", + "style": "thin" + }, + "left": { + "color": "FFCCC085", + "style": "thin" + }, + "right": { + "color": "FFCCC085", + "style": "thin" + }, + "top": { + "color": "FFCCC085", + "style": "thin" + } + }, + "fill": "FFF4ECC5", + "font": { + "b": false, + "color": null, + "i": false, + "name": "Arial", + "sz": 10.0 + }, + "nf": "#,##0.00" + } + ], + "height": 13.05, + "outline": 0 + } +} diff --git a/synthgen/tests/conftest.py b/synthgen/tests/conftest.py new file mode 100644 index 0000000..94e3af9 --- /dev/null +++ b/synthgen/tests/conftest.py @@ -0,0 +1,63 @@ +"""Общие настройки pytest для synthgen. + +Маркеры: +- `parser` — приёмка парсером tabular-parser из зеркального worktree (переменная `TP_WT`); + без окружения такие тесты пропускаются. +- `slow` — долгие прогоны; по умолчанию пропускаются, запуск: `-m slow`. +""" + +import os +from pathlib import Path + +import pytest + + +def pytest_configure(config): + config.addinivalue_line("markers", "parser: приёмка парсером из зеркального worktree (TP_WT), без окружения — skip") + config.addinivalue_line("markers", "slow: долгий прогон, по умолчанию пропускается, запуск -m slow") + + +def pytest_collection_modifyitems(config, items): + markexpr = config.getoption("-m") or "" + tp_wt = os.environ.get("TP_WT") + parser_ready = bool(tp_wt) and Path(tp_wt).is_dir() + for item in items: + if "slow" in item.keywords and "slow" not in markexpr: + item.add_marker(pytest.mark.skip(reason="долгий прогон: запуск с -m slow")) + if "parser" in item.keywords and not parser_ready: + item.add_marker(pytest.mark.skip(reason="нужен TP_WT (зеркало tabular-parser)")) + + +@pytest.fixture(scope="session") +def osv_world(): + """Небольшая группа с книгами за 3 периода (для тестов ОСВ): (cfg, group, periods, books).""" + from synthgen import dictionary + from synthgen.config import Config + from synthgen.counterparties import plan as make_plan + from synthgen.group import build_group + from synthgen.intragroup import build + from synthgen.periods import build_periods + + cfg = Config(group_size=4, periods=3, osv_counterparties_mean=8, no_inn_companies=1) + group = build_group(cfg, dictionary.load()) + periods = build_periods(cfg.start, cfg.periods) + books, _ = build(cfg, group, periods, make_plan(cfg, group)) + return cfg, group, periods, books + + +@pytest.fixture(scope="session") +def forms_world(): + """Группа с книгами за 5 периодов подряд (2025-Q1 … 2026-Q1) для тестов ББ/ОФР: сравнительные колонки + последнего периода целиком лежат в книге: (cfg, group, periods, books).""" + from synthgen import dictionary + from synthgen.config import Config + from synthgen.counterparties import plan as make_plan + from synthgen.group import build_group + from synthgen.intragroup import build + from synthgen.periods import build_periods + + cfg = Config(group_size=3, periods=5, osv_counterparties_mean=6, no_inn_companies=1) + group = build_group(cfg, dictionary.load()) + periods = build_periods(cfg.start, cfg.periods) + books, _ = build(cfg, group, periods, make_plan(cfg, group)) + return cfg, group, periods, books diff --git a/synthgen/tests/test_config.py b/synthgen/tests/test_config.py new file mode 100644 index 0000000..3666100 --- /dev/null +++ b/synthgen/tests/test_config.py @@ -0,0 +1,86 @@ +from decimal import Decimal +from pathlib import Path + +import pytest +import yaml + +from synthgen.__main__ import main +from synthgen.config import Config, ConfigError, dump_config, load_config +from synthgen.rng import rng_for + +EXAMPLE = Path(__file__).resolve().parent.parent / "examples" / "default.yaml" + + +def test_defaults(): + assert load_config(None, []) == Config() + + +def test_example_matches_defaults(): + assert load_config(EXAMPLE, []) == Config() + + +def test_yaml_and_set_override(tmp_path): + path = tmp_path / "cfg.yaml" + path.write_text("group_size: 7\n", encoding="utf-8") + assert load_config(path, []).group_size == 7 + assert load_config(path, ["group_size=9"]).group_size == 9 + assert load_config(None, ["vgo_mismatch_share=0.25"]).vgo_mismatch_share == Decimal("0.25") + + +def test_dump_roundtrip(tmp_path): + cfg = Config(group_size=12, prefix="DEMO2", vgo_mismatch_share=Decimal("0.25")) + path = tmp_path / "dump.yaml" + path.write_text(dump_config(cfg), encoding="utf-8") + assert load_config(path, []) == cfg + keys = list(yaml.safe_load(dump_config(cfg))) + assert keys == [ + "group_size", "prefix", "periods", "start", "osv_counterparties_mean", + "no_inn_companies", "vgo_mismatch_share", "seed", "out", + ] + + +@pytest.mark.parametrize( + "override", + [ + "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", + "vgo_mismatch_share=1.5", + "unknown=1", + ], +) +def test_validation(override): + with pytest.raises(ConfigError): + load_config(None, [override]) + + +def test_unknown_key_in_yaml(tmp_path): + path = tmp_path / "cfg.yaml" + path.write_text("unknown: 1\n", encoding="utf-8") + with pytest.raises(ConfigError): + load_config(path, []) + + +def test_cli_bad_config_exit_2(capsys): + assert main(["--set", "group_size=1"]) == 2 + assert capsys.readouterr().err != "" + + +def test_cli_writes_actual_config(tmp_path): + out = tmp_path / "res" + assert main(["--set", "group_size=7", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--out", str(out)]) == 0 + assert "group_size: 7" in (out / "config.yaml").read_text(encoding="utf-8") + + +def test_rng_for_stable(): + assert rng_for(1, "a").random() == rng_for(1, "a").random() + assert rng_for(1, "a").random() != rng_for(1, "b").random() + assert rng_for(1, "a").random() != rng_for(2, "a").random() diff --git a/synthgen/tests/test_counterparties.py b/synthgen/tests/test_counterparties.py new file mode 100644 index 0000000..b59a13d --- /dev/null +++ b/synthgen/tests/test_counterparties.py @@ -0,0 +1,65 @@ +from dataclasses import replace + +import pytest + +from synthgen import dictionary +from synthgen.config import Config +from synthgen.counterparties import WEIGHTS, plan +from synthgen.group import build_group + + +@pytest.fixture(scope="module") +def orgs(): + return dictionary.load() + + +def _mean_len(p): + return sum(len(v) for v in p.by_file.values()) / len(p.by_file) + + +def test_mean_matches(orgs): + cfg = Config(group_size=10, periods=4, osv_counterparties_mean=60) + p = plan(cfg, build_group(cfg, orgs)) + assert not p.capped + assert len(p.by_file) == 10 * len(WEIGHTS) + assert abs(_mean_len(p) - 60) <= 0.15 * 60 + + +def test_cap_and_flag(orgs): + cfg = Config(group_size=40, periods=4, osv_counterparties_mean=150) + g = build_group(cfg, orgs) + p = plan(cfg, g) + assert p.capped + bank_inns = {b.inn for b in g.banks} + for (code, acct), lst in p.by_file.items(): + assert len(lst) == len({o.inn for o in lst}) + if acct in ("66", "67"): + assert len(lst) <= len(g.banks) and all(o.inn in bank_inns for o in lst) + else: + assert len(lst) <= len(g.externals) + + +def test_sets_disjoint_from_group(orgs): + cfg = Config(group_size=8, osv_counterparties_mean=100) + g = build_group(cfg, orgs) + p = plan(cfg, g) + forbidden = {m.org.inn for m in g.members} | {b.inn for b in g.banks} + for (code, acct), lst in p.by_file.items(): + if acct in ("58", "60", "62", "76"): + assert not forbidden & {o.inn for o in lst} + + +def test_zero_mean(orgs): + cfg = Config(group_size=4, osv_counterparties_mean=0) + p = plan(cfg, build_group(cfg, orgs)) + assert 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): + cfg = Config(group_size=6) + g = build_group(cfg, orgs) + assert plan(cfg, g) == plan(cfg, g) + other = plan(replace(cfg, seed=2), build_group(replace(cfg, seed=2), orgs)) + assert other != plan(cfg, g) diff --git a/synthgen/tests/test_dictionary.py b/synthgen/tests/test_dictionary.py new file mode 100644 index 0000000..ab644f3 --- /dev/null +++ b/synthgen/tests/test_dictionary.py @@ -0,0 +1,104 @@ +import re +from pathlib import Path + +import openpyxl +import pytest + +from synthgen import dictionary, inn +from synthgen.dictionary import build + +FIXTURES = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures") +REAL_ECP = FIXTURES / "directories/ecp-export-russkiy-biskvit.xlsx" +REAL_OSV_DIRS = [FIXTURES / "osv/rb", FIXTURES / "osv/schastl"] +_FORMS = re.compile(r"\b(ООО|АО|ПАО|ЗАО|ОАО|ИП)\b", re.IGNORECASE) +_PARENS = re.compile(r"\(.*?(\)|$)") + + +def _norm(text: str) -> str: + """Имя без формы, скобочного хвоста, регистра, ё и пунктуации.""" + text = _PARENS.sub(" ", text) + text = _FORMS.sub(" ", text) + return re.sub(r"[^0-9a-zа-яё]+", " ", text.lower().replace("ё", "е")).strip() + + +def _real_names() -> tuple[set[str], int]: + """Нормализованные имена из ЕЦП и колонки A первых листов всех ОСВ; второе значение — число прочитанных файлов.""" + names: set[str] = set() + files = 0 + wb = openpyxl.load_workbook(REAL_ECP, read_only=True) + for ws in wb.worksheets: + rows = ws.iter_rows(values_only=True) + header = next(rows, None) + if not header or "Организация" not in header: + continue + col = list(header).index("Организация") + names.update(_norm(str(r[col])) for r in rows if r[col]) + files += 1 + for root_dir in REAL_OSV_DIRS: + for path in sorted(root_dir.rglob("*.xlsx")): + if path.name.startswith("~$") or path.name.startswith("chart-of-accounts"): + continue + wb = openpyxl.load_workbook(path, read_only=True) + ws = wb.worksheets[0] + names.update(_norm(str(r[0])) for r in ws.iter_rows(max_col=1, values_only=True) if r and r[0]) + files += 1 + names.discard("") + return names, files + + +def _fio_keys(real: set[str]) -> set[str]: + """Из «фамилия имя отчество» — «фамилия и о» (как в словаре: `Цветков А.П.`).""" + keys = set() + for name in real: + tokens = name.split() + if len(tokens) == 3 and all(len(t) > 1 for t in tokens): + keys.add(f"{tokens[0]} {tokens[1][0]} {tokens[2][0]}") + elif len(tokens) == 3 and len(tokens[1]) == 1 and len(tokens[2]) == 1: + keys.add(name) + return keys + + +def test_dictionary_size_and_kinds(): + orgs = dictionary.load() + assert len(orgs) == 400 + assert sum(o.kind == "банк" for o in orgs) == 30 + assert 30 <= sum(o.form == "ИП" for o in orgs) <= 50 + names = [o.name.lower() for o in orgs] + assert len(set(names)) == len(names) + + +def test_dictionary_inn(): + for o in dictionary.load(): + assert inn.is_valid(o.inn) + if o.form == "ИП": + assert len(o.inn) == 12 and o.kpp == "" + number = int(o.inn[2:-2]) + else: + assert len(o.inn) == 10 + assert re.fullmatch(r"\d{9}", o.kpp) + number = int(o.inn[2:-1]) + assert 100001 <= number <= 109999 + + +def test_dictionary_not_real(): + if not FIXTURES.is_dir(): + pytest.skip("нет каталога fixtures основного checkout") + real, files = _real_names() + assert files > 10 and len(real) > 500, "реальные фикстуры прочитаны не полностью" + fio = _fio_keys(real) + padded = [f" {n} " for n in real] + clash = [] + for o in dictionary.load(): + key = _norm(o.name) + if o.form == "ИП": + if key in fio or key in real: + clash.append(o.name) + elif key in real or any(f" {_norm(o.root)} " in p for p in padded): + clash.append(o.name) + assert clash == [] + + +def test_build_is_reproducible(tmp_path): + out = tmp_path / "companies.csv" + build.write_csv(out) + assert out.read_bytes() == dictionary.CSV_PATH.read_bytes() diff --git a/synthgen/tests/test_economy.py b/synthgen/tests/test_economy.py new file mode 100644 index 0000000..18a4afe --- /dev/null +++ b/synthgen/tests/test_economy.py @@ -0,0 +1,141 @@ +from datetime import date +from decimal import Decimal + +import pytest + +from synthgen import dictionary +from synthgen.config import Config +from synthgen.counterparties import plan as make_plan +from synthgen.economy import build_ledgers, last_day, months +from synthgen.group import build_group +from synthgen.periods import book_start, build_periods + +D = Decimal + + +def _build(**kw): + cfg = Config(group_size=kw.pop("group_size", 3), periods=kw.pop("periods", 4), osv_counterparties_mean=kw.pop("mean", 20), **kw) + g = build_group(cfg, dictionary.load()) + ps = build_periods(cfg.start, cfg.periods) + pl = make_plan(cfg, g) + return cfg, g, ps, pl, build_ledgers(cfg, g, ps, pl) + + +@pytest.fixture(scope="module") +def built(): + return _build() + + +def test_books_balanced(built): + cfg, g, ps, pl, books = built + start = book_start(ps) + for lg in books.values(): + for day in (start, date(2025, 3, 31), date(2025, 12, 31)): + assert sum(lg.balance(day).values()) == 0 + assert lg.balance(day, "000") == {} + assert not [p for p in lg.postings if "000" in (p.dt, p.ct) and p.day > start] + + +def test_bs_identity(built): + cfg, g, ps, pl, books = built + for lg in books.values(): + for p in ps: + day = p.end + for acct in ("90", "91"): + assert lg.balance(day, acct) == {} + if p.quarter == 4: + assert lg.balance(day, "99") == {} + bal = lg.balance(day) + assert sum(v for (a, _), v in bal.items() if a != "000") == 0 + # активы не уходят в минус: денежные средства на конец каждого месяца + for y, m in months(ps): + assert lg.balance(last_day(y, m), "51").get(("51", None), D(0)) >= 0 + + +def test_externals_used(built): + cfg, g, ps, pl, books = built + for (code, acct), lst in pl.by_file.items(): + if not lst or acct not in ("58", "60", "62", "66", "67", "76"): + continue + seen = set() + for p in books[code].postings: + for an in (p.dt_an, p.ct_an): + if an is not None and an.cp: + seen.add(an.cp) + used = sum(1 for o in lst if o.inn in seen) + assert used >= 0.8 * len(lst), (code, acct, used, len(lst)) + + +def test_pareto_shape(built): + cfg, g, ps, pl, books = built + tot = {code: sum(c for (a, _), (_, c) in lg.turnover(date(2025, 1, 1), date(2025, 12, 31), "62").items()) for code, lg in books.items()} + biggest = max(tot, key=tot.get) + by_cp = {} + for (a, an), (dt, ct) in books[biggest].turnover(date(2025, 1, 1), date(2025, 12, 31), "62").items(): + if an is not None: + by_cp[an.cp] = by_cp.get(an.cp, D(0)) + dt + ct + vals = sorted(by_cp.values(), reverse=True) + top = max(1, len(vals) // 10) + assert sum(vals[:top]) >= D("0.5") * sum(vals) + + +def test_signs_variety(built): + cfg, g, ps, pl, books = built + for acct in ("60", "62"): + signs = set() + for lg in books.values(): + for v in lg.balance(date(2025, 12, 31), acct).values(): + signs.add(v > 0) + assert signs == {True, False}, acct + + +def test_year_rollover(): + cfg, g, ps, pl, books = _build(start="2025-Q3") + dec = date(2025, 12, 31) + for lg in books.values(): + assert lg.balance(dec, "90") == {} and lg.balance(dec, "91") == {} and lg.balance(dec, "99") == {} + assert lg.balance(dec, "84") != lg.balance(date(2024, 12, 31), "84") + t = lg.turnover(date(2026, 1, 1), date(2026, 1, 1), "90") + assert t == {} # 2026 начинается с нуля; обороты 90 в январе — не 1 января + jan = lg.turnover(date(2026, 1, 1), date(2026, 1, 31), "90") + assert any(dt or ct for dt, ct in jan.values()) + assert max(p.day for lg in books.values() for p in lg.postings) == date(2026, 6, 30) + + +def test_extra_hook_called_before_close(): + cfg = Config(group_size=2, periods=1, osv_counterparties_mean=5) + g = build_group(cfg, dictionary.load()) + ps = build_periods(cfg.start, 1) + seen = [] + def hook(ledgers, year, month): + seen.append((year, month, len(ledgers["C01"].postings))) + build_ledgers(cfg, g, ps, make_plan(cfg, g), extra=hook) + assert [(y, m) for y, m, _ in seen] == [(2025, 1), (2025, 2), (2025, 3)] + + +def test_deterministic_and_zero_mean(): + a = _build(mean=0)[4] + b = _build(mean=0)[4] + assert [p for p in a["C01"].postings] == [p for p in b["C01"].postings] + assert a["C01"].postings + + +def test_90_closes_via_909(): + cfg, g, ps, pl, books = _build() + for lg in books.values(): + day = date(2025, 3, 31) + assert lg.balance(day, "90") == {} + t = lg.turnover(date(2025, 1, 1), day, "90.09") + assert t and all(dt and ct for dt, ct in t.values()) + for p in lg.postings: + if p.ct == "90.09" or p.dt == "90.09": + assert p.memo.startswith("закрытие на ") + assert not [p for p in lg.postings if p.memo == "закрытие на 99" and p.dt == "99" and p.ct.startswith("90.0") and p.ct != "90.09"] + + +def test_build_ledgers_deterministic_with_counterparties(): + a = _build(mean=30, seed=3)[4] + b = _build(mean=30, seed=3)[4] + assert a.keys() == b.keys() + for code in a: + assert a[code].postings == b[code].postings diff --git a/synthgen/tests/test_ecp.py b/synthgen/tests/test_ecp.py new file mode 100644 index 0000000..6919969 --- /dev/null +++ b/synthgen/tests/test_ecp.py @@ -0,0 +1,24 @@ +import openpyxl + +from synthgen import dictionary +from synthgen.config import Config +from synthgen.ecp import write_ecp +from synthgen.group import build_group + + +def test_ecp_format(tmp_path): + g = build_group(Config(group_size=6, prefix="DEMO2"), dictionary.load()) + path = tmp_path / "ecp-DEMO2.xlsx" + write_ecp(g, path) + wb = openpyxl.load_workbook(path) + assert wb.sheetnames == ["Выписки"] + ws = wb["Выписки"] + rows = list(ws.iter_rows(values_only=True)) + assert list(rows[0]) == ["ИНН", "КПП", "iD ГК", "Базовый РФ ГК в CRM", "Группа компаний", "Организация", "ГСК", "ГСЗ", "Тип"] + assert len(rows) - 1 == 6 + for row, member in zip(rows[1:], g.members): + assert row[0] == member.inn and row[1] == member.kpp and row[5] == member.name + assert row[2] == "DEMO2-000001" and row[4] == "ГК DEMO2" + assert row[6] == "Входит в ГСК" and row[8] == "ЮЛ" + for r in ws.iter_rows(min_row=2, max_col=2): + assert all(c.number_format == "@" and isinstance(c.value, str) for c in r) diff --git a/synthgen/tests/test_forms.py b/synthgen/tests/test_forms.py new file mode 100644 index 0000000..9dfe2e8 --- /dev/null +++ b/synthgen/tests/test_forms.py @@ -0,0 +1,252 @@ +from collections import defaultdict +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +import pytest + +from synthgen import forms +from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99 +from synthgen.periods import book_start + +ZERO = Decimal(0) +SEED = 1 +SECTIONS = { + "1100": ("1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190"), + "1200": ("1210", "1220", "1230", "1240", "1250", "1260"), + "1300": ("1310", "1320", "1340", "1350", "1360", "1370"), + "1400": ("1410", "1420", "1430", "1450"), + "1500": ("1510", "1520", "1530", "1540", "1550"), +} + + +def _thousands(x: Decimal) -> Decimal: + return (x / 1000).quantize(Decimal(1), rounding=ROUND_HALF_UP) + + +@pytest.fixture(scope="module") +def built(forms_world): + cfg, group, periods, books = forms_world + out = [] + for m in group.members: + for p in periods: + out.append((m, p, forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed))) + return out + + +def test_codes(): + assert forms.BALANCE_CODES[0] == "1110" and forms.BALANCE_CODES[-1] == "1700" and "1215" not in forms.BALANCE_CODES + assert forms.INCOME_CODES[-3:] == ("2500", "2900", "2910") + assert {"2530", "2900", "2910", "2411", "2412", "2460"} <= set(forms.INCOME_CODES) + + +def test_form_shape(built): + for m, p, bal, inc in built: + assert bal.kind == "BALANCE" and inc.kind == "INCOME" and bal.member is m and bal.period is p + assert len(bal.columns) == 3 and len(inc.columns) == 2 + assert set(bal.values) == set(forms.BALANCE_CODES) and set(inc.values) == set(forms.INCOME_CODES) + assert all(len(v) == 3 for v in bal.values.values()) and all(len(v) == 2 for v in inc.values.values()) + assert all(x == x.to_integral_value() for v in bal.values.values() for x in v), "тыс. руб. целыми" + + +def test_balance_identity(built): + for m, p, bal, _ in built: + for col in range(3): + v = {c: bal.values[c][col] for c in forms.BALANCE_CODES} + for total, parts in SECTIONS.items(): + assert v[total] == sum((v[c] for c in parts), ZERO), (m.code, p.key, col, total) + assert v["1600"] == v["1100"] + v["1200"] + assert v["1700"] == v["1300"] + v["1400"] + v["1500"] + assert v["1600"] == v["1700"], (m.code, p.key, col) + + +def test_balance_matches_ledger(built, forms_world): + _, _, _, books = forms_world + for m, p, bal, _ in built: + 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 + want = _thousands(rub[code]) + got = bal.values[code][0] + if code in bal.plugged[0]: + assert abs(got - want) == 1, (m.code, p.key, code) + 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) + + +def test_plug_is_minimal_and_recorded(built): + for _, p, bal, _ in built: + assert all(len(set(cols)) == len(cols) for cols in bal.plugged) + + +def test_first_prior_column_from_book(built, forms_world): + _, _, periods, books = forms_world + first = periods[0] + day = book_start(periods) + assert day == date(first.year - 1, 12, 31) + for m, p, bal, _ in built: + if p.year != first.year: + continue + 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 + if code in bal.plugged[1]: + assert abs(bal.values[code][1] - _thousands(rub[code])) == 1 + else: + assert bal.values[code][1] == _thousands(rub[code]), (m.code, p.key, code) + assert bal.from_book[1] and not bal.from_book[2] + + +def test_columns_inside_book_come_from_book(built, forms_world): + _, _, periods, books = forms_world + last = periods[-1] # 2026-Q1: 31.12.2025 и 31.12.2024 лежат в книге + for m, p, bal, _ in built: + if p is not last: + 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, 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) + + +def test_scaled_columns(built): + for m, p, bal, _ in built: + if bal.from_book[2]: + continue + for code in ("1150", "1210", "1230", "1250", "1410", "1520"): + base, got = bal.values[code][0], bal.values[code][2] + if base == 0: + assert got == 0 + else: + assert base * Decimal("0.8") - 1 <= got <= base * Decimal("1.1") + 1, (m.code, p.key, code, base, got) + + +def test_forms_deterministic(forms_world, built): + cfg, group, periods, books = forms_world + m, p = group.members[0], periods[0] + again = forms.balance(books[m.code], m, p, periods, cfg.seed) + assert again.values == next(b for mm, pp, b, _ in built if mm is m and pp is p).values + other = forms.balance(books[m.code], m, p, periods, cfg.seed + 1) + assert other.values["1600"][0] == again.values["1600"][0] and other.values != again.values + + +def _income_expected(ledger, start, end): + net: dict[tuple[str, str | None], Decimal] = defaultdict(Decimal) + tax = ZERO + for pst in ledger.postings: + if not start <= pst.day <= end or pst.kind in (KIND_CLOSE_90, KIND_CLOSE_99): + continue + for acct, an, sign in ((pst.dt, pst.dt_an, 1), (pst.ct, pst.ct_an, -1)): + item = an.item if an else None + if acct.startswith("90") or acct.startswith("91"): + net[(acct, item if item in ("Дивиденды", "Проценты") else None)] += sign * pst.amount + if pst.dt == "99" and pst.ct == "68.04": + tax += pst.amount + elif pst.dt == "68.04" and pst.ct == "99": + tax -= pst.amount + g = lambda a, i=None: net.get((a, i), ZERO) # noqa: E731 + return { + "2110": -g("90.01") - g("90.03"), "2120": -g("90.02"), "2210": -g("90.07"), "2220": -g("90.08"), + "2310": -g("91.01", "Дивиденды"), "2320": -g("91.01", "Проценты"), "2330": -g("91.02", "Проценты"), + "2340": -g("91.01"), "2350": -g("91.02"), "2410": -tax, + } # fmt: skip + + +def test_income_matches_ledger(built, forms_world): + _, _, _, books = forms_world + nonzero = set() + for m, p, _, inc in built: + exp = _income_expected(books[m.code], p.start, p.end) + for code, rub in exp.items(): + assert inc.values[code][0] == _thousands(rub), (m.code, p.key, code, inc.values[code][0], rub) + if rub: + nonzero.add(code) + assert {"2110", "2120", "2220", "2410"} <= nonzero # прочих доходов/расходов (2340/2350) экономика не создаёт + + +def test_income_signs(built): + for _, _, _, inc in built: + for code in ("2120", "2210", "2220", "2330", "2350"): + assert all(x <= 0 for x in inc.values[code]), code + for code in ("2110", "2310", "2320", "2340"): + assert all(x >= 0 for x in inc.values[code]), code + + +def test_2400_flow(built): + for m, p, _, inc in built: + for col in range(2): + v = {c: inc.values[c][col] for c in forms.INCOME_CODES} + assert v["2400"] == v["2300"] + v["2410"] + v["2460"], (m.code, p.key, col) + assert v["2100"] == v["2110"] + v["2120"] + assert v["2200"] == v["2100"] + v["2210"] + v["2220"] + assert v["2300"] == v["2200"] + v["2310"] + v["2320"] + v["2330"] + v["2340"] + v["2350"] + assert v["2410"] == v["2411"] + v["2412"] + assert v["2500"] == v["2400"] + v["2510"] + v["2520"] + v["2530"] + assert v["2900"] == 0 and v["2910"] == 0 and v["2460"] == 0 + + +def test_net_profit_equals_99_balance(built, forms_world): + """2400 — тот же результат, что копится на 99 (до реформации в декабре); закрытия 90.09/99 в ОФР не задваивают.""" + _, _, _, books = forms_world + checked = 0 + for m, p, _, inc in built: + if p.quarter == 4: + continue + bal99 = books[m.code].balance(p.end, "99").get(("99", None), ZERO) + assert abs(inc.values["2400"][0] * 1000 + bal99) <= 5000, (m.code, p.key) + checked += 1 + assert checked + + +def test_prior_year_column_from_book(built, forms_world): + _, _, periods, books = forms_world + last = periods[-1] # 2026-Q1: прошлогодний 1 кв. 2025 лежит в книге + for m, p, _, inc in built: + if p is not last: + continue + assert inc.from_book == [True, True] + exp = _income_expected(books[m.code], date(2025, 1, 1), date(2025, 3, 31)) + for code, rub in exp.items(): + assert inc.values[code][1] == _thousands(rub), (m.code, code) + + +def test_prior_year_column_scaled(built): + for m, p, _, inc in built: + if inc.from_book[1]: + continue + for code in ("2110", "2120", "2220"): + base, got = inc.values[code][0], inc.values[code][1] + lo, hi = sorted((base * Decimal("0.8"), base * Decimal("1.1"))) + assert lo - 1 <= got <= hi + 1, (m.code, p.key, code, base, got) + + +def test_column_labels(built): + by = {p.key: (bal, inc) for m, p, bal, inc in built if m.code == built[0][0].code} + bal, inc = by["2025-Q3"] + assert bal.columns == ["За 9 месяцев\n2025 года", "На 31 декабря\n2024 года", "На 31 декабря\n2023 года"] + assert inc.columns == ["За 9 месяцев\n2025 года", "За 9 месяцев\n2024 года"] + bal, inc = by["2025-FY"] + 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_forms_parser.py b/synthgen/tests/test_forms_parser.py new file mode 100644 index 0000000..167134f --- /dev/null +++ b/synthgen/tests/test_forms_parser.py @@ -0,0 +1,109 @@ +"""Приёмка ББ и ОФР парсером: CLI tabular-parser из зеркального worktree (`TP_WT`), маркер `parser`. + +Запуск: `$UVRUN python -m pytest synthgen/tests -q -m parser`. Без окружения тесты пропускаются. +""" + +import json +import os +import subprocess +from concurrent.futures import ThreadPoolExecutor +from decimal import Decimal +from pathlib import Path + +import pytest + +from synthgen import forms, forms_xlsx + +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" + + +def _run(path: Path) -> dict: + env = {**os.environ, "ENV_FILE": str(TP_WT / "api" / ".env.test"), "PYTHONPATH": str(TP_WT / "api")} + proc = subprocess.run( + [str(PY), "-m", "src.parser.cli", str(path)], cwd=TP_WT / "api", env=env, capture_output=True, text=True, check=False + ) + assert proc.stdout.strip(), proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def parsed(forms_world, tmp_path_factory): + if not PY.exists(): + pytest.skip(f"нет окружения парсера: {PY}") + cfg, group, periods, books = forms_world + root = tmp_path_factory.mktemp("statements") + items = [] + for m in group.members: + for p in periods: + for f in (forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed)): + path = root / f"{m.code}_{p.key}_{forms_xlsx.file_name(f)}" + forms_xlsx.write(f, path) + items.append((f, path)) + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda fp: _run(fp[1]), items)) + return [(f, p, r) for (f, p), r in zip(items, results, strict=True)] + + +def _doc(res): + assert len(res["documents"]) == 1, [d["document_type"] for d in res["documents"]] + return res["documents"][0] + + +def _issues(res): + return [(d["status"], d["checks"], [w["code"] for w in d["warnings"]]) for d in res.get("documents", [])] + + +def test_parser_status_and_type(parsed): + bad = [] + for f, path, res in parsed: + docs = res.get("documents", []) + if res.get("status") != "done" or len(docs) != 1 or docs[0]["document_type"] != f.kind: + bad.append((path.name, res.get("status"), res.get("error"), _issues(res))) + assert not bad, bad[:5] + + +def test_parser_columns_and_units(parsed): + bad = [] + for f, path, res in parsed: + doc = _doc(res) + got = [c["period_date"] for c in sorted(doc["columns"], key=lambda c: c["column_index"])] + want = [d.isoformat() for d in f.dates] + if got != want: + bad.append((path.name, got, want)) + if "тыс" not in (doc["header"].get("units") or "").lower(): + bad.append((path.name, "units", doc["header"].get("units"))) + assert not bad, bad[:5] + + +def test_parser_checks_clean(parsed): + bad = [(path.name, _doc(res)["checks"]) for f, path, res in parsed if _doc(res)["checks"]["relations_failed"]] + assert not bad, bad[:5] + + +def test_parser_values_equal_form_values_and_balance(parsed): + bad = [] + for f, path, res in parsed: + doc = _doc(res) + order = [c["column_index"] for c in sorted(doc["columns"], key=lambda c: c["column_index"])] + got: dict[str, list[Decimal]] = {} + for line in doc["lines"]: + if line["line_status"] != "ok": + continue + row = got.setdefault(line["line_code"], [Decimal(0)] * len(order)) + for v in line["values"]: + if v["amount"] is not None: + row[order.index(v["column_index"])] += Decimal(str(v["amount"])) + codes = forms.BALANCE_CODES if f.kind == "BALANCE" else forms.INCOME_CODES + for code in codes: + for col, want in enumerate(f.values[code]): + have = got.get(code, [Decimal(0)] * len(order))[col] + if have != want: + bad.append((path.name, code, col, str(have), str(want))) + if f.kind == "BALANCE": + for col in range(3): + if got["1600"][col] != got["1700"][col]: + bad.append((path.name, "1600!=1700", col)) + assert not bad, bad[:10] diff --git a/synthgen/tests/test_forms_xlsx.py b/synthgen/tests/test_forms_xlsx.py new file mode 100644 index 0000000..40c5eac --- /dev/null +++ b/synthgen/tests/test_forms_xlsx.py @@ -0,0 +1,185 @@ +import zipfile +from decimal import Decimal +from pathlib import Path + +import openpyxl +import pytest + +from synthgen import forms, forms_xlsx +from synthgen.periods import build_periods + +TEMPLATES = Path(forms_xlsx.__file__).parent / "templates" +SAMPLE_DIR = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/statements-xlsx/mezheninovskaya/2025-Q3") +FORBIDDEN = ["7016006635", "85262693", "Межениновск", "МЕЖЕНИНОВСК", "Томск", "Шегарск", "Косарев", "Лушников", "Агрофирм", "АГРОФИРМ"] +ZERO_FORMAT = '[=0]"-";General' + + +@pytest.fixture(scope="module") +def sample_forms(forms_world, tmp_path_factory): + cfg, group, periods, books = forms_world + root = tmp_path_factory.mktemp("forms") + out = [] + for m in group.members[:2]: + for p in (periods[0], periods[2], periods[3]): + for f in (forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed)): + path = root / f"{m.code}_{p.key}_{forms_xlsx.file_name(f)}" + forms_xlsx.write(f, path) + out.append((f, path)) + return out + + +def _raw_strings(path: Path) -> str: + with zipfile.ZipFile(path) as z: + return "\n".join(z.read(n).decode("utf-8", "ignore") for n in z.namelist()) + + +def _find_table(ws): + """Все таблицы форм по заголовку «Коды»: [(колонка кодов, [колонки значений], {код: строка})].""" + tables = [] + for row in ws.iter_rows(): + for c in row: + if c.value == "Коды": + cols = [x.column for x in ws[c.row + 1] if x.value is not None and x.column > c.column] + tables.append((c.column, cols, c.row)) + return tables + + +def _read_lines(ws): + """код -> [значения колонок] по всем таблицам листа.""" + result = {} + cells = {} + for code_col, value_cols, head in _find_table(ws): + for r in range(head + 2, ws.max_row + 1): + v = ws.cell(r, code_col).value + if isinstance(v, str) and v.isdigit() and len(v) == 4: + result[v] = [ws.cell(r, c).value for c in value_cols] + cells[v] = [ws.cell(r, c) for c in value_cols] + return result, cells + + +def _sample_signers() -> list[str]: + names = ["Косарев Константин Евгеньевич", "Лушникова Светлана Михайловна"] + if SAMPLE_DIR.exists(): + ws = openpyxl.load_workbook(SAMPLE_DIR / "1_Форма1_бух_09.xlsx").active + names += [ws["Q80"].value, ws["Q83"].value] + return [n for n in names if n] + + +@pytest.mark.parametrize("name,last_col", [("bb_1c.xlsx", 20), ("ofr_1c.xlsx", 18)]) +def test_template_sanitized(name, last_col): + path = TEMPLATES / name + wb = openpyxl.load_workbook(path) + ws = wb.active + for row in ws.iter_rows(): + for c in row: + assert not (isinstance(c.value, str) and c.value.startswith("=")), (c.coordinate, c.value) + if c.column > last_col: + assert c.value is None, c.coordinate + raw = _raw_strings(path) + for bad in FORBIDDEN + _sample_signers(): + assert bad not in raw, bad + lines, _ = _read_lines(ws) + assert all(v is None for vals in lines.values() for v in vals), "в шаблоне нет значений строк" + assert set(forms.BALANCE_CODES if name == "bb_1c.xlsx" else forms.INCOME_CODES) == set(lines) + + +def test_written_file_no_sample_values(sample_forms): + signers = _sample_signers() + for form, path in sample_forms: + raw = _raw_strings(path) + for bad in FORBIDDEN + signers: + assert bad not in raw, (path.name, bad) + ws = openpyxl.load_workbook(path).active + last = 20 if form.kind == "BALANCE" else 18 + assert all(c.value is None for row in ws.iter_rows(min_col=last + 1) for c in row) + assert not any(isinstance(c.value, str) and c.value.startswith("=") for row in ws.iter_rows() for c in row) + + +def test_file_name(): + ps = build_periods("2025-Q1", 4) + + class F: # только то, что нужно file_name + def __init__(self, kind, period): + self.kind, self.period = kind, period + + assert forms_xlsx.file_name(F("BALANCE", ps[2])) == "ББ_9 месяцев 2025 г.xlsx" + assert forms_xlsx.file_name(F("INCOME", ps[3])) == "ОФР_2025 г.xlsx" + + +def test_codes_and_values(sample_forms): + for form, path in sample_forms: + ws = openpyxl.load_workbook(path).active + lines, cells = _read_lines(ws) + codes = forms.BALANCE_CODES if form.kind == "BALANCE" else forms.INCOME_CODES + assert set(lines) == set(codes), path.name + for code in codes: + for col, want in enumerate(form.values[code]): + got = lines[code][col] + assert isinstance(got, int) and got == want, (path.name, code, col, got, want) + assert cells[code][col].number_format == (ZERO_FORMAT if want == 0 else "#,##0"), (code, col) + assert any(v == 0 for f, _ in sample_forms for vals in f.values.values() for v in vals) + + +def test_requisites(sample_forms): + for form, path in sample_forms: + ws = openpyxl.load_workbook(path).active + m, p = form.member, form.period + title = ws["B2"].value + assert title == ("БУХГАЛТЕРСКИЙ БАЛАНС" if form.kind == "BALANCE" else "ОТЧЕТ О ФИНАНСОВЫХ РЕЗУЛЬТАТАХ") + assert ws["B3"].value == f"за {p.label_1c}" + assert ws["G7"].value == m.name + if form.kind == "BALANCE": + assert ws["R8"].value == m.inn and ws["R6"].value == p.end.day and ws["S6"].value == p.end.month + assert ws["T6"].value == p.end.year and ws["R7"].value and ws["R7"].value != "85262693" + head = [ws["M23"].value, ws["Q23"].value, ws["S23"].value] + head2 = [ws["M48"].value, ws["Q48"].value, ws["S48"].value] + else: + assert ws["P8"].value == m.inn and ws["P6"].value == p.end.day and ws["Q6"].value == p.end.month + assert ws["R6"].value == p.end.year + head = [ws["O14"].value, ws["Q14"].value] + head2 = [ws["O36"].value, ws["Q36"].value] + assert head == form.columns and head2 == form.columns + if form.kind == "BALANCE": + assert ws["G13"].value and "Томск" not in ws["G13"].value + assert ws["G10"].value and (ws["G10"].value.startswith("АО") if m.org.form == "АО" else ws["G10"].value.startswith("ООО")) + + +def test_signers_are_synthetic(sample_forms): + form, path = sample_forms[0] + ws = openpyxl.load_workbook(path).active + cell = ws["Q80"] if form.kind == "BALANCE" else ws["O49"] + cell2 = ws["Q83"] if form.kind == "BALANCE" else ws["O52"] + assert cell.value and cell2.value and cell.value not in _sample_signers() and cell.value != cell2.value + assert len(cell.value.split()) == 3 + + +def test_style_kept(sample_forms): + for form, path in sample_forms: + name = "bb_1c.xlsx" if form.kind == "BALANCE" else "ofr_1c.xlsx" + tpl = openpyxl.load_workbook(TEMPLATES / name).active + ws = openpyxl.load_workbook(path).active + assert ws["B2"].font.name == "Times New Roman" and ws["B2"].font.b and ws["B2"].font.sz == 12 + assert {str(r) for r in ws.merged_cells.ranges} == {str(r) for r in tpl.merged_cells.ranges} and ws.merged_cells.ranges + assert {k: v.width for k, v in ws.column_dimensions.items()} == {k: v.width for k, v in tpl.column_dimensions.items()} + assert {k: v.height for k, v in ws.row_dimensions.items()} == {k: v.height for k, v in tpl.row_dimensions.items()} + _, cells = _read_lines(ws) + _, tcells = _read_lines(tpl) + code = "1150" if form.kind == "BALANCE" else "2110" + assert cells[code][0].font.name == "Times New Roman" and cells[code][0].font.sz == tcells[code][0].font.sz + assert cells[code][0].border.left.style and cells[code][0].border.left.style == tcells[code][0].border.left.style + + +def test_deterministic_bytes(sample_forms, tmp_path): + form, path = sample_forms[0] + again = tmp_path / "again.xlsx" + forms_xlsx.write(form, again) + assert again.read_bytes() == path.read_bytes() + + +def test_negative_expenses_written_as_numbers(sample_forms): + for form, path in sample_forms: + if form.kind != "INCOME": + continue + lines, _ = _read_lines(openpyxl.load_workbook(path).active) + assert all(not isinstance(v, str) for vals in lines.values() for v in vals), "числа, не текст со скобками" + assert lines["2120"][0] <= 0 and Decimal(lines["2120"][0]) == form.values["2120"][0] diff --git a/synthgen/tests/test_generate.py b/synthgen/tests/test_generate.py new file mode 100644 index 0000000..92bd826 --- /dev/null +++ b/synthgen/tests/test_generate.py @@ -0,0 +1,168 @@ +"""Сборка набора (спека §7): структура каталога, детерминированность, README прогона, граничные настройки.""" + +import hashlib +import time +from dataclasses import replace +from pathlib import Path + +import pytest + +from synthgen import generate as gen +from synthgen.__main__ import main +from synthgen.config import Config, load_config + +SMALL = Config(group_size=3, periods=2, osv_counterparties_mean=5, no_inn_companies=1) + + +def _digest(root: Path) -> dict[str, str]: + return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(root.rglob("*")) if p.is_file()} + + +@pytest.fixture(scope="module") +def default_run(tmp_path_factory): + out = tmp_path_factory.mktemp("default") + manifest = gen.generate(Config(), out) + return out, manifest + + +def test_layout(default_run): + out, manifest = default_run + assert (out / "README.md").stat().st_size > 0 + assert (out / "config.yaml").is_file() + assert (out / "directories" / "ecp-SYN.xlsx").is_file() + codes = [f"C{i:02d}" for i in range(1, 6)] + assert sorted(p.name for p in (out / "osv").iterdir()) == codes + assert sorted(p.name for p in (out / "statements").iterdir()) == codes + assert len(list(out.glob("osv/*/*/*.xlsx"))) == 5 * 4 * 9 + assert len(list(out.glob("statements/*/*/ББ_*.xlsx"))) == 5 * 4 + assert len(list(out.glob("statements/*/*/ОФР_*.xlsx"))) == 5 * 4 + assert sorted(p.name for p in (out / "osv" / "C01").iterdir()) == ["2025-FY", "2025-Q1", "2025-Q2", "2025-Q3"] + # манифест перечисляет все файлы набора + on_disk = {p for p in out.rglob("*") if p.is_file()} + assert set(manifest.files) == on_disk + + +def test_determinism(tmp_path): + a, b, c = tmp_path / "a", tmp_path / "b", tmp_path / "c" + gen.generate(SMALL, a) + gen.generate(SMALL, b) + gen.generate(replace(SMALL, seed=2), c) + assert _digest(a) == _digest(b) + assert str(a) not in (a / "config.yaml").read_text(encoding="utf-8") + d = tmp_path / "d" + gen.generate(replace(SMALL, out=d), d) + assert _digest(a) == _digest(d) # путь результата не попадает в файлы + assert _digest(a) != _digest(c) + + +def test_out_not_empty(tmp_path, capsys): + out = tmp_path / "res" + gen.generate(SMALL, out) + with pytest.raises(FileExistsError): + gen.generate(SMALL, out) + junk = out / "old.txt" + junk.write_text("старое", encoding="utf-8") + gen.generate(SMALL, out, force=True) + assert not junk.exists() + assert (out / "README.md").is_file() + + args = ["--set", "group_size=3", "--set", "periods=1", "--set", "osv_counterparties_mean=3", "--out", str(out)] + assert main(args) == 3 + assert "не пуст" in capsys.readouterr().err + assert main([*args, "--force"]) == 0 + 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 + assert (tmp_path / "out" / "ZZ" / "README.md").is_file() + + +@pytest.mark.parametrize( + "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", "mean0", "size40", "all-no-inn", "all-mismatch", "from-q4"], +) +def test_edge_configs(tmp_path, overrides): + base = {"osv_counterparties_mean": 4, "periods": 1, **overrides} + cfg = load_config(None, [f"{k}={v}" for k, v in base.items()]) + 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 + + +def _dec(v): + from decimal import Decimal + + return Decimal(str(v)) + + +def test_readme_lists_mismatches(tmp_path): + cfg = replace(SMALL, group_size=5, vgo_mismatch_share=_dec(1)) + manifest = gen.generate(cfg, tmp_path / "x") + readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8") + mismatches = [(p, m) for p in manifest.pairs for m in p.mismatches] + assert mismatches + for p, m in mismatches: + line = next((ln for ln in readme.splitlines() if p.a in ln and "→" in ln and m.period_key in ln and f"{m.pct}" in ln), None) + assert line is not None, (p.a, p.b, m) + assert "выше порога" in line or "ниже порога" in line + + +def test_readme_notes(default_run): + out, _ = default_run + readme = (out / "README.md").read_text(encoding="utf-8") + 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 + + +@pytest.mark.slow +def test_large_run_time(tmp_path): + cfg = Config(group_size=15, periods=6, osv_counterparties_mean=150) + t0 = time.monotonic() + manifest = gen.generate(cfg, tmp_path / "big") + elapsed = time.monotonic() - t0 + print(f"large run: {elapsed:.1f} s, files={len(manifest.files)}") + assert elapsed < 300 diff --git a/synthgen/tests/test_group.py b/synthgen/tests/test_group.py new file mode 100644 index 0000000..a9a6692 --- /dev/null +++ b/synthgen/tests/test_group.py @@ -0,0 +1,105 @@ +from dataclasses import replace +from decimal import Decimal + +import pytest + +from synthgen import dictionary, inn +from synthgen.config import Config +from synthgen.group import build_group, member_inn, variant_spelling +from synthgen.ecp import write_ecp +from synthgen.rng import stable_hash + + +@pytest.fixture(scope="module") +def orgs(): + return dictionary.load() + + +def test_group_size_and_head(orgs): + g = build_group(Config(group_size=5), orgs) + assert len(g.members) == 5 + assert [m.code for m in g.members] == ["C01", "C02", "C03", "C04", "C05"] + assert g.members[0].is_head and g.members[0].org.form == "АО" and g.members[0].head_share is None + for m in g.members: + assert m.org.form in ("ООО", "АО") and m.org.kind != "банк" + assert not m.is_head or m is g.members[0] + assert g.name == "ГК SYN" and g.gk_id == "SYN-000001" + for m in g.members: + assert m.name == f'{m.org.form} "SYN {m.org.root}"' + assert m.name_1c == f"SYN {m.org.root} {m.org.form}" + assert len(m.kpp) == 9 and inn.is_valid(m.inn) + + +def test_member_inn_range(): + value = member_inn("SYN", 3) + assert inn.is_valid(value) + assert int(value[2:-1]) == 1000000 + 100 * stable_hash("SYN", 90000) + 3 + + +def test_prefixes_do_not_collide(orgs): + a = {m.inn for m in build_group(Config(prefix="SYN"), orgs).members} + b = {m.inn for m in build_group(Config(prefix="DEMO2"), orgs).members} + assert not a & b + assert not (a | b) & {o.inn for o in orgs} + + +def test_externals_exclude_group_and_banks(orgs): + g = build_group(Config(group_size=8), orgs) + chosen = {m.org.inn for m in g.members} + assert all(o.kind != "банк" for o in g.externals) + assert not chosen & {o.inn for o in g.externals} + assert len(g.externals) == sum(o.kind != "банк" for o in orgs) - 8 + assert len(g.banks) == 30 and all(o.kind == "банк" for o in g.banks) + + +def test_no_inn_selection(orgs): + g = build_group(Config(group_size=5, no_inn_companies=2), orgs) + assert len(g.no_inn) == 2 and "C01" not in g.no_inn + g = build_group(Config(group_size=5, no_inn_companies=4), orgs) + assert len(g.no_inn) == 4 and "C01" not in g.no_inn + g = build_group(Config(group_size=5, no_inn_companies=5), orgs) + assert g.no_inn == frozenset(m.code for m in g.members) + assert build_group(Config(no_inn_companies=0), orgs).no_inn == frozenset() + + +@pytest.mark.parametrize("size,n", [(2, 2), (5, 2), (5, 5), (10, 3)]) +def test_variant_name(orgs, size, n): + g = build_group(Config(group_size=size, no_inn_companies=n), orgs) + by_code = {m.code: m for m in g.members} + assert set(g.variant_name) == set(g.no_inn) + for code, other in g.variant_name.items(): + assert other != code and other in by_code + m = by_code[other] + variant = variant_spelling(m) + assert variant not in (m.name_1c, m.name) + assert variant != by_code[code].name_1c + + +def test_shares(orgs): + for size in (2, 5, 12): + g = build_group(Config(group_size=size), orgs) + children = g.members[1:] + for m in children: + assert Decimal("0.51") <= m.head_share <= Decimal("1.00") + with_min = [m for m in children if m.minority is not None] + assert len(with_min) <= 2 + if size > 2: + assert 1 <= len(with_min) + for m in with_min: + assert m.head_share < 1 + for m in children: + if m.minority is None: + assert m.head_share == Decimal("1.00") + + +def test_prefix_cyrillic_and_digits(orgs, tmp_path): + for prefix in ("ДЕМО", "2GK"): + g = build_group(Config(prefix=prefix), orgs) + assert g.gk_id == f"{prefix}-000001" + write_ecp(g, tmp_path / f"ecp-{prefix}.xlsx") + + +def test_group_deterministic(orgs): + cfg = Config(group_size=7, no_inn_companies=2, seed=5) + assert build_group(cfg, orgs) == build_group(cfg, orgs) + assert build_group(cfg, orgs) != build_group(replace(cfg, seed=6), orgs) diff --git a/synthgen/tests/test_inn.py b/synthgen/tests/test_inn.py new file mode 100644 index 0000000..53a6ef0 --- /dev/null +++ b/synthgen/tests/test_inn.py @@ -0,0 +1,18 @@ +from synthgen import inn + + +def test_inn10_valid_and_region_00(): + for seq in range(1, 30): + value = inn.make_inn10(seq) + assert len(value) == 10 + assert value.startswith("00") + assert inn.is_valid(value) + assert inn.is_valid("0000000001") is False + + +def test_inn12_valid(): + for seq in range(1, 30): + value = inn.make_inn12(seq) + assert len(value) == 12 + assert value.startswith("00") + assert inn.is_valid(value) diff --git a/synthgen/tests/test_intragroup.py b/synthgen/tests/test_intragroup.py new file mode 100644 index 0000000..503ac02 --- /dev/null +++ b/synthgen/tests/test_intragroup.py @@ -0,0 +1,234 @@ +import math +from datetime import date +from decimal import Decimal + +import pytest + +from synthgen import dictionary +from synthgen.config import Config +from synthgen.counterparties import plan as make_plan +from synthgen.economy import last_day, months +from synthgen.group import build_group +from synthgen.intragroup import build, pair_balance, plan_pairs +from synthgen.periods import book_start, build_periods + +D = Decimal + + +def _make(**kw): + mean = kw.pop("mean", 10) + cfg = Config(group_size=kw.pop("group_size", 5), periods=kw.pop("periods", 6), osv_counterparties_mean=mean, **kw) + g = build_group(cfg, dictionary.load()) + ps = build_periods(cfg.start, cfg.periods) + books, pairs = build(cfg, g, ps, make_plan(cfg, g)) + return cfg, g, ps, books, pairs + + +@pytest.fixture(scope="module") +def built(): + return _make(vgo_mismatch_share=D("0.3")) + + +def test_plan_pairs_deterministic_and_loans(): + cfg = Config(group_size=6) + g = build_group(cfg, dictionary.load()) + p1, p2 = plan_pairs(cfg, g), plan_pairs(cfg, g) + assert p1 == p2 + loans = [p for p in p1 if p.kind == "loan"] + assert [p.b for p in loans] == [m.code for m in g.members[1:]] + assert all(p.mismatch is None for p in p1) and all(p.a == "C01" for p in loans) + + +def test_pairs_mirror(built): + cfg, g, ps, books, pairs = built + clean = [p for p in pairs if not p.mismatches] + assert clean + for pair in clean: + for per in ps: + ba, bb = pair_balance(books, pair, per.end) + assert ba == -bb, (pair, per.key) + assert any(pair_balance(books, p, ps[0].end)[0] != 0 for p in clean if p.kind == "trade") + assert any(pair_balance(books, p, ps[0].end)[0] != 0 for p in clean if p.kind == "loan") + + +def _years(ps): + return sorted({p.year for p in ps}) + + +def test_mismatch_share(): + cfg, g, ps, books, pairs = _make(group_size=10, vgo_mismatch_share=D("0.1"), mean=5) + n = len(pairs) + for y in _years(ps): + got = sum(1 for p in pairs for m in p.mismatches if m.year == y) + assert got == max(1, math.floor(D("0.1") * n)), y + cfg0, g0, ps0, books0, pairs0 = _make(group_size=10, vgo_mismatch_share=D("0"), mean=5, periods=2) + assert all(not p.mismatches and p.mismatch is None for p in pairs0) + + +def test_mismatch_mix(built): + cfg, g, ps, books, pairs = built + for y in _years(ps): + mms = [m for p in pairs for m in p.mismatches if m.year == y] + assert len(mms) >= 2 + small = [m for m in mms if D(1) <= m.pct <= D(4)] + large = [m for m in mms if D(8) <= m.pct <= D(25)] + assert len(small) == len(mms) // 2 + assert len(large) == len(mms) - len(mms) // 2 + for m in mms: + assert m.above_threshold == (m.pct > 5) + + +def test_mismatch_lifecycle(built): + cfg, g, ps, books, pairs = built + checked_removal = 0 + for pair in pairs: + for m in pair.mismatches: + for per in ps: + if per.year != m.year or per.key < m.period_key: + continue + ba, bb = pair_balance(books, pair, per.end) + diff = (ba + bb) # зеркальные стороны: сумма = разница по модулю со знаком стороны + assert abs(diff) == m.amount, (pair, per.key) + # у стороны с проводкой модуль сальдо больше + with_entry = ba if m.side == pair.a else bb + other = bb if m.side == pair.a else ba + assert abs(with_entry) - abs(other) == m.amount + # pct — от меньшей стороны на конец периода расхождения (в следующих периодах сальдо торговли иное) + if per.key == m.period_key: + assert abs(m.pct - (m.amount / abs(other) * 100)) < D("0.01") + jan = date(m.year + 1, 1, 31) + if jan <= ps[-1].end: + ba, bb = pair_balance(books, pair, jan) + assert ba == -bb + checked_removal += 1 + assert checked_removal > 0 + + +def test_dividends_no_balance(built): + cfg, g, ps, books, pairs = built + codes = {m.code for m in g.members} + for lg in books.values(): + for per in ps: + assert lg.balance(per.end, "76.03") == {} + head = books["C01"] + t = head.turnover(date(2025, 4, 1), date(2025, 6, 30), "76.03") + assert t and all(dt > 0 and ct > 0 for dt, ct in t.values()) + for code in codes - {"C01"}: + t = books[code].turnover(date(2025, 4, 1), date(2025, 6, 30), "76.03") + assert t and any(ct > 0 for dt, ct in t.values()) + + +def test_shares(built): + cfg, g, ps, books, pairs = built + start = book_start(ps) + head = books["C01"] + h58 = {an.cp: v for (a, an), v in head.balance(start, "58.01").items()} + assert set(h58) == {m.code for m in g.members[1:]} + for m in g.members[1:]: + c80 = {an.cp: v for (a, an), v in books[m.code].balance(start, "80.09").items()} + assert c80[head.company] == -h58[m.code] + if m.minority is None: + assert set(c80) == {"C01"} and m.head_share == D("1.00") + else: + assert set(c80) == {"C01", m.minority.inn} + assert c80[m.minority.inn] < 0 + # миноритарий не попал к головной + assert not any(an.cp in {m.minority.inn for m in g.members if m.minority} for (_, an) in head.balance(start, "58") if an) + assert any(m.minority for m in g.members) + + +def test_books_still_balanced(built): + cfg, g, ps, books, pairs = built + start = book_start(ps) + for lg in books.values(): + for day in (start, date(2025, 3, 31), date(2025, 12, 31), ps[-1].end): + assert sum(lg.balance(day).values()) == 0 + assert lg.balance(day, "000") == {} + assert not [p for p in lg.postings if "000" in (p.dt, p.ct) and p.day > start] + for per in ps: + for acct in ("90", "91"): + assert lg.balance(per.end, acct) == {} + for y, mth in months(ps): + assert lg.balance(last_day(y, mth), "51").get(("51", None), D(0)) >= 0, (lg.company, y, mth) + + +def test_group_analytics_not_mixed(built): + cfg, g, ps, books, pairs = built + codes = {m.code for m in g.members} + ext = {o.inn for o in g.externals} + seen_group = seen_ext = False + for lg in books.values(): + for acct, an in lg.balance(ps[-1].end): + if an is None or not an.cp: + continue + if acct.startswith(("60", "62")): + assert an.cp in codes or an.cp in ext, (acct, an) + seen_group |= an.cp in codes + seen_ext |= an.cp in ext + assert not (an.cp in codes and an.cp in ext) + assert seen_group and seen_ext + for lg in books.values(): + for p in lg.postings: + for acct, an in ((p.dt, p.dt_an), (p.ct, p.ct_an)): + if an is not None and an.cp in codes: + assert acct.split(".")[0] in {"58", "60", "62", "66", "67", "76", "80", "91"}, acct + + +def test_loan_mismatch_on_principal(built): + cfg, g, ps, books, pairs = built + loans = [p for p in pairs if p.kind == "loan" and p.mismatches] + assert loans + for p in loans: + for m in p.mismatches: + memos = {x.memo for x in books[p.a].postings + books[p.b].postings if x.memo == "транш в пути"} + assert memos == {"транш в пути"} + ba = _sum_loan(books[p.a], m, "58.03", p.b) + assert ba != 0 + for per in ps: # проценты не копятся: 76.09 на конец любого периода = 0 + assert books[p.a].balance(per.end, "76.09") == {} and books[p.b].balance(per.end, "76.09") == {} + + +def _sum_loan(lg, m, acct, cp): + return sum(v for (a, an), v in lg.balance(date(m.year, 12, 31), acct).items() if an is not None and an.cp == cp) + + +def test_seller_cap(built): + cfg, g, ps, books, pairs = built + codes = {m.code for m in g.members} + for code, lg in books.items(): + rev = lg.meta["model"].rev_year + for year in {p.year for p in ps}: + tot = sum( + (x.amount for x in lg.postings if x.memo == "реализация (ВГО)" and x.day.year == year), + D(0), + ) + assert tot <= D("0.4") * D("1.2") * rev * D("1.1") + D("0.01"), (code, year) + assert any(x.memo == "реализация (ВГО)" for lg in books.values() for x in lg.postings) + + +def test_build_deterministic(): + r1 = _make(vgo_mismatch_share=D("0.3"), periods=4) + r2 = _make(vgo_mismatch_share=D("0.3"), periods=4) + assert r1[4] == r2[4] + for code in r1[3]: + assert r1[3][code].postings == r2[3][code].postings + + +def test_mismatch_odd_count(): + cfg = Config(group_size=5) + n = len(plan_pairs(cfg, build_group(cfg, dictionary.load()))) + share = D("3.5") / n # k = floor(3.5) = 3: одна малая, две крупные + cfg, g, ps, books, pairs = _make(group_size=5, vgo_mismatch_share=share, periods=2) + for y in _years(ps): + mms = [m for p in pairs for m in p.mismatches if m.year == y] + assert len(mms) == 3 + assert sum(1 for m in mms if m.pct < 5) == 1 + assert sum(1 for m in mms if m.pct > 5) == 2 + + +def test_edge_group_two(): + cfg, g, ps, books, pairs = _make(group_size=2, periods=4, vgo_mismatch_share=D("0.5")) + assert [p.kind for p in pairs if p.kind == "loan"] == ["loan"] + for lg in books.values(): + assert sum(lg.balance(ps[-1].end).values()) == 0 + assert any(p.mismatches for p in pairs) diff --git a/synthgen/tests/test_ledger.py b/synthgen/tests/test_ledger.py new file mode 100644 index 0000000..e9c5073 --- /dev/null +++ b/synthgen/tests/test_ledger.py @@ -0,0 +1,93 @@ +import csv +import os +from datetime import date +from decimal import Decimal +from pathlib import Path + +import pytest + +from synthgen import chart +from synthgen.ledger import An, Ledger, opening + +D = Decimal + + +def test_double_entry(): + lg = Ledger("AGRO") + lg.post(date(2025, 1, 10), "62.01", An("AGRO"), "90.01", None, D("1200.00"), "продажа") + lg.post(date(2025, 2, 10), "51", None, "62.01", An("AGRO"), D("200.00"), "оплата") + opening(lg, date(2025, 1, 1), "51", None, D("500.00")) + opening(lg, date(2025, 1, 1), "60.01", An("AGRO"), D("-70.00")) + for d in (date(2025, 1, 1), date(2025, 1, 31), date(2025, 12, 31)): + assert sum(lg.balance(d).values()) == 0 + + +def test_balance_signs_and_prefix(): + lg = Ledger("X") + lg.post(date(2025, 1, 10), "62.01", An("AGRO"), "90.01", None, D("1200.00"), "") + lg.post(date(2025, 2, 10), "51", None, "62.01", An("AGRO"), D("200.00"), "") + assert lg.balance(date(2025, 3, 31), "62") == {("62.01", An("AGRO")): D("1000.00")} + assert lg.balance(date(2025, 1, 31), "62") == {("62.01", An("AGRO")): D("1200.00")} + + +def test_turnover_window(): + lg = Ledger("X") + lg.post(date(2024, 12, 31), "62.01", An("A"), "90.01", None, D("10.00"), "") + lg.post(date(2025, 1, 1), "62.01", An("A"), "90.01", None, D("20.00"), "") + lg.post(date(2025, 3, 31), "51", None, "62.01", An("A"), D("5.00"), "") + lg.post(date(2025, 4, 1), "62.01", An("A"), "90.01", None, D("40.00"), "") + t = lg.turnover(date(2025, 1, 1), date(2025, 3, 31), "62") + assert t == {("62.01", An("A")): (D("20.00"), D("5.00"))} + + +def test_csv_deterministic(tmp_path): + def build(p: Path): + lg = Ledger("X") + lg.post(date(2025, 1, 10), "62.01", An("A", contract="Д-1"), "90.01", None, D("1.50"), "м") + lg.to_csv(p) + return p.read_bytes() + + a = build(tmp_path / "a.csv") + assert a == build(tmp_path / "b.csv") + rows = list(csv.reader(a.decode("utf-8").splitlines(), delimiter=";")) + assert rows[0] == "day;dt;dt_cp;dt_contract;dt_item;ct;ct_cp;ct_contract;ct_item;amount;memo".split(";") + assert rows[1][:3] == ["2025-01-10", "62.01", "A"] + + +def test_rejects_bad_amount(): + lg = Ledger("X") + with pytest.raises(ValueError): + lg.post(date(2025, 1, 1), "51", None, "62.01", None, D("0"), "") + + +def test_chart_helpers(): + assert chart.parent("76.07.1") == "76.07" + assert chart.parent("76.07") == "76" + assert chart.parent("58.01") == "58" + assert chart.is_off_balance("001") and chart.is_off_balance("011") + assert not chart.is_off_balance("01") + + +def test_chart_codes_in_core_seed(): + ws = Path(os.environ.get("ARM_KA_WORKSPACE", "/Users/andrey.epifancev/projects/awp-ca")) + seed = ws / "arm-core/api/seed/core/chart_of_accounts.csv" + if not seed.exists(): + pytest.skip("нет seed плана счетов") + codes = {r["code"] for r in csv.DictReader(seed.open(encoding="utf-8"))} + missing = [c for c in chart.ACCOUNTS if c != "000" and c not in codes] + assert not missing + + +def test_closing_postings_have_explicit_kind(osv_world): + """T3.8: закрывающие проводки различаются полем kind, а не разбором memo.""" + from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, KIND_CLOSE_COST, KIND_ORDINARY + + _, _, _, books = osv_world + kinds = {"закрытие на 90.09": KIND_CLOSE_90, "закрытие на 99": KIND_CLOSE_99, "закрытие затрат": KIND_CLOSE_COST} + seen = set() + for lg in books.values(): + for p in lg.postings: + want = kinds.get(p.memo, KIND_ORDINARY) + assert p.kind == want, (p.memo, p.kind) + seen.add(p.kind) + assert {KIND_CLOSE_90, KIND_CLOSE_99, KIND_CLOSE_COST, KIND_ORDINARY} <= seen diff --git a/synthgen/tests/test_osv.py b/synthgen/tests/test_osv.py new file mode 100644 index 0000000..7a15eef --- /dev/null +++ b/synthgen/tests/test_osv.py @@ -0,0 +1,229 @@ +from datetime import timedelta +from decimal import Decimal + +import openpyxl +import pytest + +from synthgen import osv +from synthgen.periods import build_periods + +ZERO = Decimal(0) +SAMPLE_MERGES = { + "A9:D9", "A10:D11", "E9:I9", "E10:G11", "H10:I11", "J9:N9", "J10:K11", "L10:N11", "O9:Q9", "O10:P11", "Q10:Q11", +} + + +def _pick(osv_world, account="62", inn=True): + cfg, group, periods, books = osv_world + member = next(m for m in group.members if (m.code not in group.no_inn) == inn) + period = periods[-1] + data = osv.rows(books[member.code], account, period, member, group) + return member, period, data, group, books + + +def _write(tmp_path, osv_world, account="62", inn=True): + member, period, data, group, books = _pick(osv_world, account, inn) + path = tmp_path / osv.file_name(account, period) + osv.write(path, member, account, period, data, with_inn=inn) + return openpyxl.load_workbook(path).active, member, period, data + + +def test_file_name(): + ps = build_periods("2025-Q1", 4) + assert osv.file_name("60", ps[2]) == "сч 60_9 месяцев 2025 г.xlsx" + assert osv.file_name("62", ps[3]) == "сч 62_2025 г.xlsx" + + +def test_header_texts(tmp_path, osv_world): + ws, member, period, _ = _write(tmp_path, osv_world, "60") + assert ws["A1"].value == member.name + assert ws["A2"].value == f"Оборотно-сальдовая ведомость по счету 60 за {period.label_1c}" + text9 = " ".join(str(c.value) for c in ws[9] if c.value) + text10 = " ".join(str(c.value) for c in ws[10] if c.value) + assert "Счет" in text9 and "Сальдо на начало периода" in text9 + assert "Обороты за период" in text9 and "Сальдо на конец периода" in text9 + assert "Контрагенты" in text10 and "Дебет" in text10 + " ".join(str(c.value) for c in ws[11] if c.value) + assert "Кредит" in text10 + " ".join(str(c.value) for c in ws[11] if c.value) + + +def test_style_matches_sample(tmp_path, osv_world): + ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False) + assert ws["A12"].font.name == "Arial" and ws["A12"].font.sz == 8 + assert ws["A2"].font.name == "Arial" and ws["A2"].font.sz == 12 and ws["A2"].font.b + assert ws["A9"].fill.fgColor.rgb == "FFF4ECC5" + assert ws["A12"].fill.fgColor.rgb == "FFF8F2D8" + cp_row = next(i for i, r in enumerate(data, start=12) if r.is_cp) + sub_row = next(i for i, r in enumerate(data, start=12) if r.level == 1) + assert ws.cell(cp_row, 1).fill.fill_type is None + assert ws["A9"].border.left.style and ws.cell(cp_row, 5).border.bottom.style + assert ws.sheet_properties.outlinePr.summaryBelow is False + assert ws.row_dimensions[sub_row].outlineLevel == 1 and ws.row_dimensions[cp_row].outlineLevel == 2 + assert ws.cell(sub_row, 1).alignment.indent == 1 and ws.cell(cp_row, 1).alignment.indent == 2 + number = next(c for c in ws[cp_row] if isinstance(c.value, float)) + assert number.number_format == "#,##0.00" + + +@pytest.mark.parametrize("inn", [False, True]) +def test_every_amount_has_number_format(tmp_path, osv_world, inn): + """Все суммы (сальдо и обороты, строки счёта, субсчёта, контрагента, «Итого») — `#,##0.00`, как в 1С.""" + for account in ("60", "62", "76"): + (tmp_path / account).mkdir() + ws, *_ = _write(tmp_path / account, osv_world, account, inn=inn) + bad = [c.coordinate for row in ws.iter_rows(min_row=12) for c in row + if isinstance(c.value, (int, float)) and c.number_format != "#,##0.00"] + assert not bad, (account, bad[:10]) + + +def test_inn_header_styled_like_header(tmp_path, osv_world): + ws, *_ = _write(tmp_path, osv_world, "60", inn=True) + for r in (9, 10): # E10:E11 объединена — вид задаёт верхняя ячейка + assert ws.cell(r, 5).fill.fgColor.rgb == ws.cell(r, 1).fill.fgColor.rgb == "FFF4ECC5", r + assert ws.cell(r, 5).border.left.style and ws.cell(r, 5).border.top.style, r + assert ws["E10"].font.name == "Arial" and ws["E10"].font.sz == 9 + + +def test_merges_follow_sample(tmp_path, osv_world): + ws, *_ = _write(tmp_path, osv_world, "62", inn=False) + got = {str(m) for m in ws.merged_cells.ranges if m.min_row in (9, 10, 11)} + assert SAMPLE_MERGES <= got + (tmp_path / "x").mkdir() + ws2, *_ = _write(tmp_path / "x", osv_world, "62", inn=True) + got2 = {str(m) for m in ws2.merged_cells.ranges if m.min_row in (9, 10, 11)} + assert {"A9:D9", "A10:D11", "E10:E11", "F9:J9", "F10:H11", "I10:J11", "K10:L11", "M10:O11", "P10:Q11", "R10:R11"} <= got2 + assert ws2["E10"].value == "ИНН" + + +def test_zero_is_empty(tmp_path, osv_world): + ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False) + for i, row in enumerate(data, start=12): + spans = [(5, 7), (8, 9), (10, 11), (12, 14), (15, 16), (17, 17)] + for (a, _), value in zip(spans, row.six, strict=True): + cell = ws.cell(i, a).value + assert (cell is None) if value == 0 else (cell == float(value)) + + +def test_total_row(tmp_path, osv_world): + ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False) + last = 11 + len(data) + 1 + assert ws.cell(last, 1).value == "Итого" + totals = osv.total_of(data) + account_row = next(r for r in data if r.level == 0) + assert totals == account_row.six + for (a, _), value in zip([(5, 7), (8, 9), (10, 11), (12, 14), (15, 16), (17, 17)], totals, strict=True): + assert (ws.cell(last, a).value or 0) == pytest.approx(float(value)) + + +def test_no_inn_file(tmp_path, osv_world): + ws, member, _, data = _write(tmp_path, osv_world, "62", inn=False) + assert ws.max_column <= 17 + assert all(not (isinstance(c.value, str) and c.value.isdigit() and len(c.value) == 10) for c in ws["A"]) + assert all(r.inn is None for r in data) + assert all(ws.cell(i, 5).value is None or isinstance(ws.cell(i, 5).value, float) for i in range(12, 12 + len(data))) + + +def test_rows_match_ledger(osv_world): + """Каждая строка контрагента равна Ledger.balance/turnover по ключу (субсчёт, контрагент).""" + cfg, group, periods, books = osv_world + checked = 0 + for member in group.members: + ledger = books[member.code] + for period in periods: + data = osv.rows(ledger, "62", period, member, group) + opening = ledger.balance(period.start - timedelta(days=1), "62") + closing = ledger.balance(period.end, "62") + turn = ledger.turnover(period.start, period.end, "62") + + def fold(src, pick): + out = {} + for (code, an), v in src.items(): + key = (code, an.cp if an else None) + out[key] = out.get(key, ZERO) + pick(v) + return out + + want = { + "o": fold(opening, lambda v: v), "c": fold(closing, lambda v: v), + "td": fold(turn, lambda v: v[0]), "tc": fold(turn, lambda v: v[1]), + } + code = None + seen = set() + for r in data: + if not r.is_cp: + code = r.label + continue + key = (code, r.cp) + seen.add(key) + assert r.od - r.oc == want["o"].get(key, ZERO), (member.code, period.key, key) + assert r.cd - r.cc == want["c"].get(key, ZERO), (member.code, period.key, key) + assert r.td == want["td"].get(key, ZERO) and r.tc == want["tc"].get(key, ZERO), (member.code, period.key, key) + checked += 1 + # обратное: всё ненулевое из книги попало в строки + for kind, src in want.items(): + for key, v in src.items(): + if v != 0: + assert key in seen, (member.code, period.key, kind, key) + assert checked > 20 + + +def test_parent_sums(osv_world): + _, _, data, _, _ = _pick(osv_world, "76") + assert data[0].level == 0 + subs = [r for r in data if r.level == 1] + for col in range(6): + assert data[0].six[col] == sum(r.six[col] for r in subs) + # у субсчёта контрагенты суммируются в его строку + for i, r in enumerate(data): + if r.level == 1: + kids = [] + for k in data[i + 1:]: + if k.level <= 1: + break + if k.is_cp: + kids.append(k) + if kids: + assert r.six == tuple(sum(k.six[c] for k in kids) for c in range(6)) + + +def test_group_names(osv_world): + """В ОСВ без ИНН ровно один член группы записан вариантным именем, остальные — `name_1c`; с ИНН — все `name_1c`.""" + cfg, group, periods, books = osv_world + by_code = {m.code: m for m in group.members} + variants_seen = 0 + for member in group.members: + others = [m for m in group.members if m.code != member.code] + for account in ("60", "62", "76"): + for period in periods: + data = osv.rows(books[member.code], account, period, member, group) + cps = {r.cp: r for r in data if r.is_cp} + for m in others: + if m.code not in cps: + continue + r = cps[m.code] + if member.code in group.no_inn and group.variant_name[member.code] == m.code: + assert r.label == osv.variant_spelling(m) != m.name_1c + variants_seen += 1 + else: + assert r.label == m.name_1c, (member.code, account, period.key, m.code) + assert r.inn == (None if member.code in group.no_inn else m.inn) + if member.code in group.no_inn: + variant = by_code[group.variant_name[member.code]] + assert sum(1 for r in data if r.is_cp and r.label == variant.name_1c) == 0 + assert variants_seen > 0, "вариантное написание нигде не встретилось" + + +def test_osv_without_externals(tmp_path): + from synthgen import dictionary + from synthgen.config import Config + from synthgen.counterparties import plan as make_plan + from synthgen.group import build_group + from synthgen.intragroup import build + + cfg = Config(group_size=3, periods=2, osv_counterparties_mean=0) + group = build_group(cfg, dictionary.load()) + ps = build_periods(cfg.start, cfg.periods) + books, _ = build(cfg, group, ps, make_plan(cfg, group)) + member = group.members[0] + data = osv.rows(books[member.code], "60", ps[-1], member, group) + path = tmp_path / "x.xlsx" + osv.write(path, member, "60", ps[-1], data, with_inn=member.code not in group.no_inn) + ws = openpyxl.load_workbook(path).active + assert any(c.value == "Итого" for c in ws["A"]) diff --git a/synthgen/tests/test_osv_parser.py b/synthgen/tests/test_osv_parser.py new file mode 100644 index 0000000..39bb38f --- /dev/null +++ b/synthgen/tests/test_osv_parser.py @@ -0,0 +1,92 @@ +"""Приёмка ОСВ парсером: CLI tabular-parser из зеркального worktree (`TP_WT`), маркер `parser`. + +Запуск: `$UVRUN python -m pytest synthgen/tests -q -m parser`. Без окружения тесты пропускаются. +""" + +import json +import os +import subprocess +from decimal import Decimal +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 = OSV_ACCOUNTS # все счета набора (решение 43: приёмка расширена с 60/62/76) + + +def _run(path: Path) -> dict: + env = {**os.environ, "ENV_FILE": str(TP_WT / "api" / ".env.test"), "PYTHONPATH": str(TP_WT / "api")} + proc = subprocess.run( + [str(PY), "-m", "src.parser.cli", str(path)], cwd=TP_WT / "api", env=env, capture_output=True, text=True, check=False + ) + assert proc.stdout.strip(), proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def parsed(osv_world, tmp_path_factory): + if not PY.exists(): + pytest.skip(f"нет окружения парсера: {PY}") + cfg, group, periods, books = osv_world + root = tmp_path_factory.mktemp("osv") + out = [] + for member in group.members: + with_inn = member.code not in group.no_inn + for account in ACCOUNTS: + for period in (periods[0], periods[-1]): + data = osv.rows(books[member.code], account, period, member, group) + path = root / f"{member.code}_{period.key}_{osv.file_name(account, period)}" + osv.write(path, member, account, period, data, with_inn=with_inn) + out.append((member, account, period, data, with_inn, _run(path))) + return out + + +def test_parser_status_and_account(parsed): + bad = [] + for member, account, period, _, _, res in parsed: + docs = res.get("documents", []) + if res.get("status") != "done" or len(docs) != 1 or docs[0]["header"]["account_code_raw"] != account: + bad.append((member.code, account, period.key, res.get("status"), res.get("error"))) + assert not bad, bad[:10] + + +def test_parser_inn(parsed, osv_world): + _, group, _, _ = osv_world + group_names = {m.name_1c: m.inn for m in group.members} + 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 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"]]: + bad.append((member.code, account, r["counterparty_name"], r["counterparty_inn"])) + elif any(r["counterparty_inn"] for r in got): + bad.append((member.code, account, "ИНН у ОСВ без ИНН")) + assert not bad, bad[:10] + + +def test_parser_totals(parsed): + """Сумма корневой строки счёта в документе = «Итого» листа = суммам `rows`; проверки парсера чистые.""" + keys = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") + cent = Decimal("0.01") + bad = [] + for member, account, period, data, _, res in parsed: + doc = res["documents"][0] + 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)): + bad.append((member.code, account, period.key, got)) + assert not bad, bad[:10] diff --git a/synthgen/tests/test_periods.py b/synthgen/tests/test_periods.py new file mode 100644 index 0000000..afc04f3 --- /dev/null +++ b/synthgen/tests/test_periods.py @@ -0,0 +1,23 @@ +from datetime import date + +from synthgen.periods import book_end, book_start, build_periods + + +def test_build_periods_simple(): + ps = build_periods("2025-Q1", 4) + assert [p.key for p in ps] == ["2025-Q1", "2025-Q2", "2025-Q3", "2025-FY"] + assert [p.label_1c for p in ps] == ["1 квартал 2025 г.", "1 полугодие 2025 г.", "9 месяцев 2025 г.", "2025 г."] + assert all(p.start == date(2025, 1, 1) for p in ps) + assert [p.end for p in ps] == [date(2025, 3, 31), date(2025, 6, 30), date(2025, 9, 30), date(2025, 12, 31)] + + +def test_build_periods_rollover(): + ps = build_periods("2025-Q3", 4) + assert [p.key for p in ps] == ["2025-Q3", "2025-FY", "2026-Q1", "2026-Q2"] + assert ps[2].start == date(2026, 1, 1) and ps[2].year == 2026 and ps[2].quarter == 1 + assert book_end(ps) == date(2026, 6, 30) + + +def test_book_start(): + assert book_start(build_periods("2025-Q1", 4)) == date(2024, 12, 31) + assert book_start(build_periods("2025-Q3", 4)) == date(2024, 12, 31) diff --git a/synthgen/xlsx.py b/synthgen/xlsx.py new file mode 100644 index 0000000..46fd2fc --- /dev/null +++ b/synthgen/xlsx.py @@ -0,0 +1,37 @@ +"""Источник: synthetic-group/gen/xlsx.py (#35). Детерминированное сохранение xlsx: фиксированные метки книги и записей zip.""" + +import datetime +import io +import re +import zipfile +from pathlib import Path + +import openpyxl + +FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0) +ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0) +CREATOR = "arm-ka synthetic" +_FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode() +_CORE_PART = "docProps/core.xml" +_MODIFIED_RE = re.compile(rb"(]*>)[^<]*()") + + +def save_deterministic(wb: openpyxl.Workbook, path: Path) -> None: + wb.properties.creator = CREATOR + wb.properties.lastModifiedBy = CREATOR + wb.properties.created = FIXED_TIME + wb.properties.modified = FIXED_TIME + raw = io.BytesIO() + wb.save(raw) + out = io.BytesIO() + with zipfile.ZipFile(raw) as source, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target: + for info in source.infolist(): + clone = zipfile.ZipInfo(info.filename, date_time=ZIP_DATE_TIME) + clone.compress_type = zipfile.ZIP_DEFLATED + clone.external_attr = info.external_attr + content = source.read(info.filename) + if info.filename == _CORE_PART: + content = _MODIFIED_RE.sub(rb"\g<1>" + _FIXED_W3CDTF + rb"\g<2>", content) + target.writestr(clone, content) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(out.getvalue())