Andrey Epifancev 004c21041c synthgen: сборка набора, README, детерминированность (#38)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 00:37:42 +04:00

116 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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