142 lines
9.8 KiB
Python
142 lines
9.8 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)}» (корень, префикс и форма без кавычек). Ядро "
|
||
"такое имя автоматически не сопоставит (имя нормализуется с сохранением порядка слов): нужна ручная привязка алиаса."
|
||
)
|
||
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"
|