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