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/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index 406e605..5e4eace 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -301,3 +301,4 @@ README прогона, точные субсчета внутри перечис | 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` — указывать только каталог набора; пустой файл ОСВ может смутить загрузчик, ждущий непустых | diff --git a/synthgen/README.md b/synthgen/README.md new file mode 100644 index 0000000..e143ccf --- /dev/null +++ b/synthgen/README.md @@ -0,0 +1,72 @@ +# 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 без аналитики; правило 6 ядра суммирует 62↔60 и 76↔76; счёт 80 головной без аналитики; + парсером проверены ОСВ 60/62/76; подпись «За 3 месяца» ОФР не сверена с реальной выгрузкой — подробности в README + каждого прогона. + +## Как пересобрать словарь и шаблоны + +- Словарь организаций: `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/__main__.py b/synthgen/__main__.py index 23bcf3d..2be5683 100644 --- a/synthgen/__main__.py +++ b/synthgen/__main__.py @@ -1,6 +1,7 @@ """Точка входа: `python -m synthgen --config synthgen/examples/default.yaml --set group_size=12 --out DIR`. -Пока печатает итоговые настройки (спека §3); генерация набора добавится в следующих задачах. +Генерирует набор (спека §7) и печатает путь и сводку. Каталог результата по умолчанию — `./out/`; +непустой каталог без `--force` — код 3. """ import argparse @@ -8,7 +9,8 @@ import sys from dataclasses import replace from pathlib import Path -from synthgen.config import ConfigError, dump_config, load_config +from synthgen import generate +from synthgen.config import ConfigError, load_config def _parser() -> argparse.ArgumentParser: @@ -18,8 +20,8 @@ def _parser() -> argparse.ArgumentParser: "--set", dest="overrides", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переопределение настройки (можно повторять)", ) - parser.add_argument("--out", type=Path, default=None, help="каталог результата") - parser.add_argument("--force", action="store_true", help="перезаписать существующий каталог результата") + parser.add_argument("--out", type=Path, default=None, help="каталог результата (по умолчанию ./out/)") + parser.add_argument("--force", action="store_true", help="очистить и перезаписать непустой каталог результата") return parser @@ -32,7 +34,15 @@ def main(argv: list[str] | None = None) -> int: return 2 if args.out is not None: cfg = replace(cfg, out=args.out) - sys.stdout.write(dump_config(cfg)) + 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 diff --git a/synthgen/generate.py b/synthgen/generate.py new file mode 100644 index 0000000..251b647 --- /dev/null +++ b/synthgen/generate.py @@ -0,0 +1,71 @@ +"""Сборка набора (спека §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 + + +def _prepare(out: Path, force: bool) -> None: + if out.exists() and any(out.iterdir()): + if not force: + raise FileExistsError(f"каталог результата не пуст: {out} (--force перезапишет)") + 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/report.py b/synthgen/report.py new file mode 100644 index 0000000..3b91123 --- /dev/null +++ b/synthgen/report.py @@ -0,0 +1,115 @@ +"""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)}» (корень, префикс и форма без кавычек). Сопоставит ли ядро " + "такое имя с компанией группы автоматически, не проверено (OPEN-QUESTION O-2)." + ) + 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 %" + lines.append(f"| {p.a} → {p.b} | {KIND[p.kind]} | {m.period_key} | {m.side} | {_money(m.amount)} | {m.pct} % | {verdict} |") + 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н. +- ВГО по выручке (90.01) в ОСВ идут без аналитики по контрагентам (M-4, T4.6): взаимные продажи внутри группы + видны только по расчётным счетам, и при элиминации выручка может попасть в строку 2110 дважды. +- Правило 6 ядра суммирует обороты пар 62 ↔ 60 и 76 ↔ 76 (M-5, T4.7): расхождение одной пары по расчётам и по + займам/прочим расчётам складывается, а не сравнивается раздельно. +- Счёт 80 у головной компании без аналитики (T4.10): строка 80 не раскрывается по участникам. +- Парсером tabular-parser проверены только ОСВ по счетам 60, 62 и 76 (O-5); остальные счета (01, 58, 66, 67, 80, 90) + покрыты юнит-тестами формы. +- Подпись колонки ОФР «За 3 месяца» (и «6 месяцев», «9 месяцев») сделана по образцу 1С, с реальной выгрузкой не + сверена (O-6); строки 2340 и 2350 в ОФР всегда нулевые. +- Счёт 58 с внешними контрагентами (займы 58.03) и 76.05 только по Кт — OPEN-QUESTION O-3: нужна проверка, как ядро + трактует такие внешние стороны. +- Вариантное имя контрагента в ОСВ без ИНН: см. раздел выше (O-2). +""" + + +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/tests/test_config.py b/synthgen/tests/test_config.py index 466df1f..3666100 100644 --- a/synthgen/tests/test_config.py +++ b/synthgen/tests/test_config.py @@ -74,9 +74,10 @@ def test_cli_bad_config_exit_2(capsys): assert capsys.readouterr().err != "" -def test_cli_prints_config(capsys): - assert main(["--set", "group_size=7"]) == 0 - assert "group_size: 7" in capsys.readouterr().out +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(): diff --git a/synthgen/tests/test_generate.py b/synthgen/tests/test_generate.py new file mode 100644 index 0000000..374e3d1 --- /dev/null +++ b/synthgen/tests/test_generate.py @@ -0,0 +1,136 @@ +"""Сборка набора (спека §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_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}, + {"group_size": 40, "periods": 1, "osv_counterparties_mean": 10}, + {"no_inn_companies": 5}, + {"vgo_mismatch_share": 1}, + {"start": "2025-Q4", "periods": 2}, + ], + ids=["size2", "size40", "all-no-inn", "all-mismatch", "from-q4"], +) +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 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", "62", "76", "80", "58", "За 3 месяца", "вариантн"): + assert needle 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