"""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"