116 lines
7.2 KiB
Python
116 lines
7.2 KiB
Python
"""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"
|