synthgen: правки по финальному ревью (mean=0, ББ займов, --force, README по ядру) (#38)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-13 01:04:15 +04:00
parent 004c21041c
commit 0abe99bf77
11 changed files with 166 additions and 44 deletions

View File

@ -302,3 +302,6 @@ README прогона, точные субсчета внутри перечис
| 38 | 12.11.2026 | ОФР: 2411 = 2410 (налог текущий), 2412 = 0, 2460/2510/2520/2530/2900/2910 = «-»; 2500 = 2400 + 2510 + 2520 + 2530; подписи колонок: «За 3 месяца / 6 месяцев / 9 месяцев\n<год> года», для года — «За <год> год» (ОФР) и «На 31 декабря\n<год> года» (ББ); дата шапки — конец периода | отложенных налогов и прочих результатов модель не создаёт; подписи по образцу 1С, парсер определяет даты колонок | OPEN-QUESTION: форма подписей «За 3 месяца» для 1 квартала у реальных выгрузок не проверена | | 38 | 12.11.2026 | ОФР: 2411 = 2410 (налог текущий), 2412 = 0, 2460/2510/2520/2530/2900/2910 = «-»; 2500 = 2400 + 2510 + 2520 + 2530; подписи колонок: «За 3 месяца / 6 месяцев / 9 месяцев\n<год> года», для года — «За <год> год» (ОФР) и «На 31 декабря\n<год> года» (ББ); дата шапки — конец периода | отложенных налогов и прочих результатов модель не создаёт; подписи по образцу 1С, парсер определяет даты колонок | OPEN-QUESTION: форма подписей «За 3 месяца» для 1 квартала у реальных выгрузок не проверена |
| 39 | 12.11.2026 | В шаблонах ББ/ОФР сохранены флаги образца (`M15`/`R15` ББ, нумерация колонок ОФР); реквизиты (ОКПО, ОКВЭД по отрасли, ОКОПФ 65/47, адрес, ФИО подписантов) — детерминированные вымышленные из `stable_hash` по ИНН/компании/периоду | формы 66н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами | | 39 | 12.11.2026 | В шаблонах ББ/ОФР сохранены флаги образца (`M15`/`R15` ББ, нумерация колонок ОФР); реквизиты (ОКПО, ОКВЭД по отрасли, ОКОПФ 65/47, адрес, ФИО подписантов) — детерминированные вымышленные из `stable_hash` по ИНН/компании/периоду | формы 66н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами |
| 40 | 13.11.2026 | Сборка набора: пустые по правилу 1С ОСВ тоже пишутся (шапка и «Итого» с нулями), число файлов не зависит от экономики; каталог результата по умолчанию `./out/<prefix>`; `--force` очищает содержимое `out` целиком; в `config.yaml` и README `out` всегда `null` (путь результата в файлы не попадает) | детерминированность побайтно при разных `--out`; ожидаемое число файлов предсказуемо (компании × периоды × 9 счетов) | `--force` стирает всё в `out` — указывать только каталог набора; пустой файл ОСВ может смутить загрузчик, ждущий непустых | | 40 | 13.11.2026 | Сборка набора: пустые по правилу 1С ОСВ тоже пишутся (шапка и «Итого» с нулями), число файлов не зависит от экономики; каталог результата по умолчанию `./out/<prefix>`; `--force` очищает содержимое `out` целиком; в `config.yaml` и README `out` всегда `null` (путь результата в файлы не попадает) | детерминированность побайтно при разных `--out`; ожидаемое число файлов предсказуемо (компании × периоды × 9 счетов) | `--force` стирает всё в `out` — указывать только каталог набора; пустой файл ОСВ может смутить загрузчик, ждущий непустых |
| 41 | 13.11.2026 | I-1: при `osv_counterparties_mean = 0` файлы 60, 62 и 76 получают по одному служебному внешнему контрагенту (первый из выборки словаря), остальные счета остаются без внешних | внешние обороты без аналитики и ВГО с аналитикой на одном субсчёте 60.01/62.01 запрещены свёрткой ОСВ; строка «<...>» потребовала бы отдельной проверки парсером | при mean = 0 «внешних» не ноль, а по одному в 60/62/76 (README оговаривает) |
| 42 | 13.11.2026 | I-3: в ББ головной 58.03 по аналитике: заём дочерней с кредиторским 67.03 → 1170, с 66.03 → 1240, внешние 58.03 → 1240 (раньше все 58.03 головной — 1170); `balance_rub` получил параметр `seed` | правила 3/4 ядра сопоставляют 1170↔1410 и 1240↔1510; срок займа в synthgen случайный | внешние 58.03 головной теперь в 1240 (оборотные), а не в 1170 |
| 43 | 13.11.2026 | I-4: `--force` чистит только каталог с `config.yaml` и README, начинающимся с «# Синтетический набор»; отказ при `.git` и если `out` — файл (код 3); приёмка парсером расширена на все 9 счетов; README прогона переписан по поведению ядра (MULTIPLE_RULES, правила 3/4/6/7/8) | `--force` при `--out .` стирал `.git`; прежние формулировки о правиле 6 и 2110 не подтвердились по ядру | набор с вручную удалённым README `--force` не очистит (удалять вручную) |

View File

@ -59,9 +59,13 @@ uv run --with openpyxl==3.1.5 --with pyyaml==6.0.2 python -m synthgen \
- Не оракул; только форма 66н (ФСБУ 4/2023 не покрыта). - Не оракул; только форма 66н (ФСБУ 4/2023 не покрыта).
- Среднее число контрагентов ограничено словарём (≤ 150): при потолке README набора пишет «среднее ограничено словарём». - Среднее число контрагентов ограничено словарём (≤ 150): при потолке README набора пишет «среднее ограничено словарём».
- ИНН компаний группы берутся из диапазона по хешу префикса; два префикса могут столкнуться (малая вероятность). - ИНН компаний группы берутся из диапазона по хешу префикса; два префикса могут столкнуться (малая вероятность).
- ВГО по выручке 90.01 без аналитики; правило 6 ядра суммирует 62↔60 и 76↔76; счёт 80 головной без аналитики; - Выручка 90.01 в ОСВ без аналитики по контрагентам; счёт 80 головной без аналитики; подпись «За 3 месяца» в ОФР не
парсером проверены ОСВ 60/62/76; подпись «За 3 месяца» ОФР не сверена с реальной выгрузкой — подробности в README сверена с реальной выгрузкой первого квартала; внешние 58.03 и 76.05 только по Кт не проверялись на ядре.
каждого прогона. - Побайтная воспроизводимость — в пределах одной платформы (float в весах и масштабах).
- Как ядро увидит расхождения (торговля и заём выше 5 % — нераспределённые строки `MULTIPLE_RULES`, до 5 % — автоматически),
какие особенности у данных и как прочитать пары — в README каждого прогона.
- `--force` чистит только каталог, который узнаёт как набор synthgen (`config.yaml` и README с заголовком набора), и
отказывает при `.git` в каталоге.
## Как пересобрать словарь и шаблоны ## Как пересобрать словарь и шаблоны

View File

@ -18,6 +18,7 @@ from synthgen.rng import rng_for
OSV_ACCOUNTS = ("01", "58", "60", "62", "66", "67", "76", "80", "90") OSV_ACCOUNTS = ("01", "58", "60", "62", "66", "67", "76", "80", "90")
WEIGHTS = {"60": 1.6, "62": 1.6, "76": 1.0, "66": 0.1, "67": 0.05, "58": 0.1} WEIGHTS = {"60": 1.6, "62": 1.6, "76": 1.0, "66": 0.1, "67": 0.05, "58": 0.1}
BANK_ACCOUNTS = ("66", "67") BANK_ACCOUNTS = ("66", "67")
SERVICE_ACCOUNTS = ("60", "62", "76") # при mean = 0 в этих файлах один служебный внешний контрагент
@dataclass(frozen=True) @dataclass(frozen=True)
@ -56,6 +57,8 @@ def plan(cfg: Config, group: Group) -> CounterpartyPlan:
cap = len(pool) cap = len(pool)
mu = cfg.osv_counterparties_mean * w / avg_w mu = cfg.osv_counterparties_mean * w / avg_w
want = [_draw_n(rng_for(cfg.seed, "cp-count", code, acct), mu) for code in codes] want = [_draw_n(rng_for(cfg.seed, "cp-count", code, acct), mu) for code in codes]
if cfg.osv_counterparties_mean == 0 and acct in SERVICE_ACCOUNTS:
want = [1] * len(codes) # решение 41: при mean = 0 расчёты идут через одного служебного внешнего контрагента
got = [min(n, cap) for n in want] got = [min(n, cap) for n in want]
shortfall = sum(want) - sum(got) shortfall = sum(want) - sum(got)
if shortfall > 0: if shortfall > 0:

View File

@ -11,16 +11,19 @@
Закрывающие проводки месяца (`KIND_CLOSE_90`, `KIND_CLOSE_99`) в ОФР не входят, закрытие затрат (`KIND_CLOSE_COST`) входит. Закрывающие проводки месяца (`KIND_CLOSE_90`, `KIND_CLOSE_99`) в ОФР не входят, закрытие затрат (`KIND_CLOSE_COST`) входит.
""" """
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from synthgen.chart import is_off_balance from synthgen.chart import is_off_balance
from synthgen.group import Member from synthgen.group import Member
from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, Ledger from synthgen.intragroup import loan_account_for_seed
from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, An, Ledger
from synthgen.periods import Period, book_end, book_start from synthgen.periods import Period, book_end, book_start
from synthgen.rng import rng_for from synthgen.rng import rng_for
_MEMBER_CODE = re.compile(r"C\d{2,}") # аналитика контрагента-компании группы (внешние — ИНН)
D = Decimal D = Decimal
ZERO = D(0) ZERO = D(0)
ONE = D(1) ONE = D(1)
@ -67,7 +70,7 @@ class Form:
# ---------------------------------------------------------------- рубли # ---------------------------------------------------------------- рубли
def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]: def _classify(acct: str, an: An | None, bal: Decimal, member: Member, seed: int) -> tuple[str, Decimal]:
"""(код строки, сумма со знаком строки) для одного сальдо (Дт > 0).""" """(код строки, сумма со знаком строки) для одного сальдо (Дт > 0)."""
root = acct.split(".")[0] root = acct.split(".")[0]
if root in ("01", "02", "03"): if root in ("01", "02", "03"):
@ -85,7 +88,11 @@ def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]:
if acct == "58.01": if acct == "58.01":
return "1170", bal return "1170", bal
if acct == "58.03": if acct == "58.03":
return ("1170" if member.is_head else "1240"), bal # займы головной — на срок > 12 мес. # решение 42: заём дочерней (аналитика — её код) по сроку её кредиторского субсчёта: 67.03 → 1170, 66.03 → 1240
# (правила 3/4 ядра); внешние 58.03 — 1240
if an is not None and an.cp is not None and _MEMBER_CODE.fullmatch(an.cp) and loan_account_for_seed(seed, an.cp) == "67.03":
return "1170", bal
return "1240", bal
if root in RECEIVABLE_ROOTS: if root in RECEIVABLE_ROOTS:
if acct == "76.07.1" and bal < 0: if acct == "76.07.1" and bal < 0:
return "1450", -bal # арендные обязательства считаем долгосрочными return "1450", -bal # арендные обязательства считаем долгосрочными
@ -101,12 +108,12 @@ def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]:
raise ValueError(f"{member.code}: нет правила строки ББ для {acct} (сальдо {bal})") raise ValueError(f"{member.code}: нет правила строки ББ для {acct} (сальдо {bal})")
def _balance_leaves(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]: def _balance_leaves(ledger: Ledger, at: date, member: Member, seed: int) -> dict[str, Decimal]:
leaves = {c: ZERO for c in ASSET_LEAVES + LIABILITY_LEAVES} leaves = {c: ZERO for c in ASSET_LEAVES + LIABILITY_LEAVES}
for (acct, _), bal in sorted(ledger.balance(at).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): for (acct, an), bal in sorted(ledger.balance(at).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
if acct == "000" or is_off_balance(acct): if acct == "000" or is_off_balance(acct):
continue continue
code, amount = _classify(acct, bal, member) code, amount = _classify(acct, an, bal, member, seed)
leaves[code] += amount leaves[code] += amount
return leaves return leaves
@ -120,9 +127,9 @@ def _add_totals(values: dict[str, Decimal]) -> dict[str, Decimal]:
return out return out
def balance_rub(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]: def balance_rub(ledger: Ledger, at: date, member: Member, seed: int) -> dict[str, Decimal]:
"""Код строки -> рубли (спека §5.6 п. 3): сальдо по строке, 000 и забалансовые не входят.""" """Код строки -> рубли (спека §5.6 п. 3): сальдо по строке, 000 и забалансовые не входят."""
full = _add_totals(_balance_leaves(ledger, at, member)) full = _add_totals(_balance_leaves(ledger, at, member, seed))
if full["1600"] != full["1700"]: if full["1600"] != full["1700"]:
raise ValueError(f"{ledger.company} на {at}: актив {full['1600']} != пассив {full['1700']}") raise ValueError(f"{ledger.company} на {at}: актив {full['1600']} != пассив {full['1700']}")
return {c: full[c] for c in BALANCE_CODES} return {c: full[c] for c in BALANCE_CODES}
@ -188,6 +195,8 @@ def _plug(rub: dict[str, Decimal]) -> tuple[dict[str, Decimal], tuple[str, ...]]
if rub[c] == 0: if rub[c] == 0:
continue continue
delta = step if c in ASSET_LEAVES else -step delta = step if c in ASSET_LEAVES else -step
if rub[c] > 0 and out[c] + delta < 0:
continue # подгонка не должна превращать положительную строку в отрицательную
cands.append((abs(out[c] + delta - rub[c] / THOUSAND), c, delta)) cands.append((abs(out[c] + delta - rub[c] / THOUSAND), c, delta))
cands.sort() cands.sort()
need = int(abs(diff)) need = int(abs(diff))
@ -238,7 +247,7 @@ def balance(ledger: Ledger, member: Member, period: Period, ps: list[Period], se
base_rub: dict[str, Decimal] | None = None base_rub: dict[str, Decimal] | None = None
for i, day in enumerate(dates): for i, day in enumerate(dates):
if lo <= day <= hi: if lo <= day <= hi:
rub = balance_rub(ledger, day, member) rub = balance_rub(ledger, day, member, seed)
base_rub = base_rub or rub base_rub = base_rub or rub
values, plug = _plug(rub) values, plug = _plug(rub)
from_book.append(True) from_book.append(True)

View File

@ -24,10 +24,31 @@ def default_out(cfg: Config) -> Path:
return cfg.out if cfg.out is not None else Path("out") / cfg.prefix return cfg.out if cfg.out is not None else Path("out") / cfg.prefix
SET_MARKER = "# Синтетический набор"
class NotASetError(FileExistsError):
"""`--force` отказывается чистить каталог, не похожий на набор synthgen."""
def _looks_like_set(out: Path) -> bool:
readme, config = out / "README.md", out / "config.yaml"
if not (readme.is_file() and config.is_file()):
return False
with readme.open(encoding="utf-8", errors="replace") as fh:
return fh.readline().startswith(SET_MARKER)
def _prepare(out: Path, force: bool) -> None: def _prepare(out: Path, force: bool) -> None:
if out.exists() and not out.is_dir():
raise FileExistsError(f"каталог результата не каталог, а файл: {out}")
if out.exists() and any(out.iterdir()): if out.exists() and any(out.iterdir()):
if not force: if not force:
raise FileExistsError(f"каталог результата не пуст: {out} (--force перезапишет)") raise FileExistsError(f"каталог результата не пуст: {out} (--force перезапишет прежний набор)")
if (out / ".git").exists():
raise NotASetError(f"{out} содержит .git: чистить такой каталог нельзя")
if not _looks_like_set(out):
raise NotASetError(f"{out} не похож на набор synthgen (нет config.yaml и README.md с заголовком набора): не чищу")
for item in out.iterdir(): for item in out.iterdir():
shutil.rmtree(item) if item.is_dir() and not item.is_symlink() else item.unlink() shutil.rmtree(item) if item.is_dir() and not item.is_symlink() else item.unlink()
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)

View File

@ -61,9 +61,13 @@ class VgoPair:
mismatches: tuple[Mismatch, ...] = () # по одному на год, где пара выбрана (решение 27) mismatches: tuple[Mismatch, ...] = () # по одному на год, где пара выбрана (решение 27)
def loan_account(cfg: Config, child_code: str) -> str: def loan_account_for_seed(seed: int, child_code: str) -> str:
"""Субсчёт кредитора по займу головной: краткосрочный 66.03 или долгосрочный 67.03.""" """Субсчёт кредитора по займу головной: краткосрочный 66.03 или долгосрочный 67.03."""
return "66.03" if rng_for(cfg.seed, "loan-term", child_code).random() < 0.5 else "67.03" return "66.03" if rng_for(seed, "loan-term", child_code).random() < 0.5 else "67.03"
def loan_account(cfg: Config, child_code: str) -> str:
return loan_account_for_seed(cfg.seed, child_code)
def plan_pairs(cfg: Config, group: Group) -> list[VgoPair]: def plan_pairs(cfg: Config, group: Group) -> list[VgoPair]:

View File

@ -47,8 +47,8 @@ def _no_inn(group: Group) -> list[str]:
other = by_code[group.variant_name[code]] other = by_code[group.variant_name[code]]
lines.append( lines.append(
f"- {code}: ОСВ без колонки ИНН; контрагент {other.code} ({other.name}) записан в ней вариантным " f"- {code}: ОСВ без колонки ИНН; контрагент {other.code} ({other.name}) записан в ней вариантным "
f"написанием «{variant_spelling(other)}» (корень, префикс и форма без кавычек). Сопоставит ли ядро " f"написанием «{variant_spelling(other)}» (корень, префикс и форма без кавычек). Ядро "
"такое имя с компанией группы автоматически, не проверено (OPEN-QUESTION O-2)." "такое имя автоматически не сопоставит (имя нормализуется с сохранением порядка слов): нужна ручная привязка алиаса."
) )
return [*lines, ""] return [*lines, ""]
@ -63,12 +63,20 @@ def _pairs(group: Group, pairs: list[VgoPair]) -> list[str]:
return [*lines, "", "Расхождений сторон в этом прогоне нет.", ""] return [*lines, "", "Расхождений сторон в этом прогоне нет.", ""]
lines += [ lines += [
"", "Расхождения: сумма — разница сторон, % — от меньшей стороны сальдо пары на конец периода; порог 5 %.", "", "", "Расхождения: сумма — разница сторон, % — от меньшей стороны сальдо пары на конец периода; порог 5 %.", "",
"| Пара (продавец/займодавец → покупатель/заёмщик) | Вид | Период | Проводка у | Сумма, руб. | % | Порог |", "| Пара (продавец/займодавец → покупатель/заёмщик) | Вид | Период | Проводка у | Сумма, руб. | % | Порог | Ожидание в ядре |",
"|---|---|---|---|---|---|---|", "|---|---|---|---|---|---|---|---|",
] # fmt: skip ] # fmt: skip
for p, m in mism: for p, m in mism:
verdict = "выше порога 5 %" if m.above_threshold else "ниже порога 5 %" 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} |") 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, ""] return [*lines, ""]
@ -89,19 +97,37 @@ def _counts(plan: CounterpartyPlan, group: Group) -> list[str]:
NOTES = """\ NOTES = """\
## Что нужно знать при чтении результатов ## Что нужно знать при чтении результатов
- Набор не оракул: ожидаемых итогов консолидации здесь нет, README — журнал того, что заложил генератор. Только форма 66н. Набор не оракул: ожидаемых итогов консолидации здесь нет, README — журнал того, что заложил генератор. Только форма 66н.
- ВГО по выручке (90.01) в ОСВ идут без аналитики по контрагентам (M-4, T4.6): взаимные продажи внутри группы Одинаковые настройки и seed дают побайтно одинаковые файлы в пределах одной платформы (веса и масштабы считаются во float,
видны только по расчётным счетам, и при элиминации выручка может попасть в строку 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С, с реальной выгрузкой не - расхождение до 5 % и пары без расхождений закрываются точными или «до 5 %» упаковками — автоматически;
сверена (O-6); строки 2340 и 2350 в ОФР всегда нулевые. - торговля с расхождением выше 5 %: строка 62.01 продавца (по дебету) входит в правила 6, 7 и 8, поэтому она
- Счёт 58 с внешними контрагентами (займы 58.03) и 76.05 только по Кт — OPEN-QUESTION O-3: нужна проверка, как ядро нераспределённая (`MULTIPLE_RULES`), а 60.01 покупателя (только правило 6) уходит в правило 6 односторонне (`AUTO`);
трактует такие внешние стороны. на экране «Пары ВГО» (API `elimination-totals`) такая пара подсвечивается;
- Вариантное имя контрагента в ОСВ без ИНН: см. раздел выше (O-2). - заём с расхождением выше 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 остаётся по одному служебному внешнему контрагенту, чтобы строки
расчётов с компаниями группы и внешние обороты не смешивались на одном субсчёте.
""" """

View File

@ -52,7 +52,9 @@ def test_sets_disjoint_from_group(orgs):
def test_zero_mean(orgs): def test_zero_mean(orgs):
cfg = Config(group_size=4, osv_counterparties_mean=0) cfg = Config(group_size=4, osv_counterparties_mean=0)
p = plan(cfg, build_group(cfg, orgs)) p = plan(cfg, build_group(cfg, orgs))
assert all(v == [] for v in p.by_file.values()) and not p.capped assert not p.capped
for (code, acct), v in p.by_file.items():
assert len(v) == (1 if acct in ("60", "62", "76") else 0), (code, acct) # решение 41
def test_plan_deterministic(orgs): def test_plan_deterministic(orgs):

View File

@ -61,9 +61,8 @@ def test_balance_identity(built):
def test_balance_matches_ledger(built, forms_world): def test_balance_matches_ledger(built, forms_world):
_, _, _, books = forms_world _, _, _, books = forms_world
adjusted = 0
for m, p, bal, _ in built: for m, p, bal, _ in built:
rub = forms.balance_rub(books[m.code], p.end, m) rub = forms.balance_rub(books[m.code], p.end, m, forms_world[0].seed)
for code in forms.BALANCE_CODES: for code in forms.BALANCE_CODES:
if code in SECTIONS or code in ("1600", "1700"): if code in SECTIONS or code in ("1600", "1700"):
continue continue
@ -71,11 +70,9 @@ def test_balance_matches_ledger(built, forms_world):
got = bal.values[code][0] got = bal.values[code][0]
if code in bal.plugged[0]: if code in bal.plugged[0]:
assert abs(got - want) == 1, (m.code, p.key, code) assert abs(got - want) == 1, (m.code, p.key, code)
adjusted += 1
else: else:
assert got == want, (m.code, p.key, code, got, want) assert got == want, (m.code, p.key, code, got, want)
assert bal.values["1600"][0] == sum((bal.values[c][0] for c in SECTIONS["1100"] + SECTIONS["1200"]), ZERO) assert bal.values["1600"][0] == sum((bal.values[c][0] for c in SECTIONS["1100"] + SECTIONS["1200"]), ZERO)
assert adjusted >= 0
def test_plug_is_minimal_and_recorded(built): def test_plug_is_minimal_and_recorded(built):
@ -91,7 +88,7 @@ def test_first_prior_column_from_book(built, forms_world):
for m, p, bal, _ in built: for m, p, bal, _ in built:
if p.year != first.year: if p.year != first.year:
continue continue
rub = forms.balance_rub(books[m.code], day, m) rub = forms.balance_rub(books[m.code], day, m, forms_world[0].seed)
for code in forms.BALANCE_CODES: for code in forms.BALANCE_CODES:
if code in SECTIONS or code in ("1600", "1700"): if code in SECTIONS or code in ("1600", "1700"):
continue continue
@ -110,7 +107,7 @@ def test_columns_inside_book_come_from_book(built, forms_world):
continue continue
assert bal.from_book == [True, True, True] assert bal.from_book == [True, True, True]
for col, day in enumerate((p.end, date(2025, 12, 31), date(2024, 12, 31))): for col, day in enumerate((p.end, date(2025, 12, 31), date(2024, 12, 31))):
rub = forms.balance_rub(books[m.code], day, m) rub = forms.balance_rub(books[m.code], day, m, forms_world[0].seed)
for code in ("1150", "1230", "1250", "1520", "1370"): for code in ("1150", "1230", "1250", "1520", "1370"):
assert abs(bal.values[code][col] - _thousands(rub[code])) <= 1, (m.code, col, code) assert abs(bal.values[code][col] - _thousands(rub[code])) <= 1, (m.code, col, code)
@ -235,3 +232,21 @@ def test_column_labels(built):
assert bal.columns[0] == "На 31 декабря\n2025 года" and inc.columns == ["За 2025 год", "За 2024 год"] assert bal.columns[0] == "На 31 декабря\n2025 года" and inc.columns == ["За 2025 год", "За 2024 год"]
assert by["2025-Q1"][1].columns[0] == "За 3 месяца\n2025 года" assert by["2025-Q1"][1].columns[0] == "За 3 месяца\n2025 года"
assert by["2025-Q2"][1].columns[1] == "За 6 месяцев\n2024 года" assert by["2025-Q2"][1].columns[1] == "За 6 месяцев\n2024 года"
def test_head_loans_by_term(forms_world):
"""Решение 42: займы головной классифицируются по сроку кредиторского субсчёта дочерней (правила 3/4 ядра)."""
from synthgen.intragroup import loan_account
cfg, group, periods, books = forms_world
head = group.members[0]
at = periods[0].end
rub = forms.balance_rub(books[head.code], at, head, cfg.seed)
bal = books[head.code].balance(at)
terms = {c.code: loan_account(cfg, c.code) for c in group.members[1:]}
assert {"67.03", "66.03"} <= set(terms.values()) or len(terms) < 3
long_ = sum((v for (a, an), v in bal.items() if a == "58.03" and an and terms.get(an.cp) == "67.03"), ZERO)
other = sum((v for (a, an), v in bal.items() if a == "58.03" and not (an and terms.get(an.cp) == "67.03")), ZERO)
assert long_ > 0
assert rub["1170"] == long_ + sum((v for (a, _), v in bal.items() if a == "58.01"), ZERO)
assert rub["1240"] >= other

View File

@ -73,6 +73,29 @@ def test_out_not_empty(tmp_path, capsys):
assert str(out) in capsys.readouterr().out assert str(out) in capsys.readouterr().out
def test_force_refuses_foreign_dir(tmp_path, capsys):
foreign = tmp_path / "foreign"
foreign.mkdir()
(foreign / "data.txt").write_text("чужое", encoding="utf-8")
with pytest.raises(FileExistsError):
gen.generate(SMALL, foreign, force=True)
assert (foreign / "data.txt").exists()
args = ["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--force"]
assert main([*args, "--out", str(foreign)]) == 3
assert "не похож на набор" in capsys.readouterr().err
repo = tmp_path / "repo"
gen.generate(SMALL, repo)
(repo / ".git").mkdir()
with pytest.raises(FileExistsError):
gen.generate(SMALL, repo, force=True)
assert (repo / ".git").exists()
not_dir = tmp_path / "file.txt"
not_dir.write_text("x", encoding="utf-8")
assert main([*args, "--out", str(not_dir)]) == 3
def test_default_out_is_prefix_dir(tmp_path, monkeypatch): def test_default_out_is_prefix_dir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
assert main(["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--set", "prefix=ZZ"]) == 0 assert main(["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--set", "prefix=ZZ"]) == 0
@ -83,12 +106,13 @@ def test_default_out_is_prefix_dir(tmp_path, monkeypatch):
"overrides", "overrides",
[ [
{"group_size": 2}, {"group_size": 2},
{"osv_counterparties_mean": 0},
{"group_size": 40, "periods": 1, "osv_counterparties_mean": 10}, {"group_size": 40, "periods": 1, "osv_counterparties_mean": 10},
{"no_inn_companies": 5}, {"no_inn_companies": 5},
{"vgo_mismatch_share": 1}, {"vgo_mismatch_share": 1},
{"start": "2025-Q4", "periods": 2}, {"start": "2025-Q4", "periods": 2},
], ],
ids=["size2", "size40", "all-no-inn", "all-mismatch", "from-q4"], ids=["size2", "mean0", "size40", "all-no-inn", "all-mismatch", "from-q4"],
) )
def test_edge_configs(tmp_path, overrides): def test_edge_configs(tmp_path, overrides):
base = {"osv_counterparties_mean": 4, "periods": 1, **overrides} base = {"osv_counterparties_mean": 4, "periods": 1, **overrides}
@ -96,6 +120,12 @@ def test_edge_configs(tmp_path, overrides):
manifest = gen.generate(cfg, tmp_path / "x") manifest = gen.generate(cfg, tmp_path / "x")
readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8") readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8")
assert readme.strip() assert readme.strip()
if cfg.group_size <= 5: # каждый файл набора открывается
import openpyxl
for path in manifest.files:
if path.suffix == ".xlsx":
openpyxl.load_workbook(path).close()
if not manifest.pairs: if not manifest.pairs:
assert "Пар ВГО нет" in readme assert "Пар ВГО нет" in readme
@ -121,8 +151,10 @@ def test_readme_lists_mismatches(tmp_path):
def test_readme_notes(default_run): def test_readme_notes(default_run):
out, _ = default_run out, _ = default_run
readme = (out / "README.md").read_text(encoding="utf-8") readme = (out / "README.md").read_text(encoding="utf-8")
for needle in ("90.01", "62", "76", "80", "58", "За 3 месяца", "вариантн"): for needle in ("90.01", "MULTIPLE_RULES", "правил", "80", "58", "За 3 месяца", "вариантн", "ручн", "платформ"):
assert needle in readme assert needle in readme
for marker in ("M-4", "M-5", "T4.", "O-2", "O-3", "O-5", "O-6", "OPEN-QUESTION"):
assert marker not in readme
assert "не оракул" in readme assert "не оракул" in readme

View File

@ -12,12 +12,13 @@ from pathlib import Path
import pytest import pytest
from synthgen import osv from synthgen import osv
from synthgen.counterparties import OSV_ACCOUNTS
pytestmark = pytest.mark.parser pytestmark = pytest.mark.parser
TP_WT = Path(os.environ.get("TP_WT") or "/Users/andrey.epifancev/projects/awp-ca/.wt/synthetic-group/tabular-parser") TP_WT = Path(os.environ.get("TP_WT") or "/Users/andrey.epifancev/projects/awp-ca/.wt/synthetic-group/tabular-parser")
PY = TP_WT / "api" / ".venv" / "bin" / "python" PY = TP_WT / "api" / ".venv" / "bin" / "python"
ACCOUNTS = ("60", "62", "76") ACCOUNTS = OSV_ACCOUNTS # все счета набора (решение 43: приёмка расширена с 60/62/76)
def _run(path: Path) -> dict: def _run(path: Path) -> dict:
@ -62,7 +63,7 @@ def test_parser_inn(parsed, osv_world):
bad = [] bad = []
for member, account, period, data, with_inn, res in parsed: for member, account, period, data, with_inn, res in parsed:
got = [r for r in res["documents"][0]["rows"] if r["group_type"] == "COUNTERPARTY" and r["data_type"] == "BU"] got = [r for r in res["documents"][0]["rows"] if r["group_type"] == "COUNTERPARTY" and r["data_type"] == "BU"]
assert got, (member.code, account, period.key) assert bool(got) == any(r.is_cp for r in data), (member.code, account, period.key) # 01/80/90 без контрагентов
if with_inn: if with_inn:
for r in got: for r in got:
if r["counterparty_name"] in group_names and r["counterparty_inn"] != group_names[r["counterparty_name"]]: if r["counterparty_name"] in group_names and r["counterparty_inn"] != group_names[r["counterparty_name"]]:
@ -82,6 +83,8 @@ def test_parser_totals(parsed):
checks = doc["checks"] checks = doc["checks"]
if not checks["total_row_matches_root"] or checks["parent_sum_violations"] or checks["balance_identity_violations"]: if not checks["total_row_matches_root"] or checks["parent_sum_violations"] or checks["balance_identity_violations"]:
bad.append((member.code, account, period.key, checks)) bad.append((member.code, account, period.key, checks))
if not data: # пустая ОСВ: только «Итого»
continue
root = next(r for r in doc["rows"] if r["group_type"] == "ACCOUNT" and r["data_type"] == "BU") root = next(r for r in doc["rows"] if r["group_type"] == "ACCOUNT" and r["data_type"] == "BU")
got = tuple(Decimal(str(root[k] or 0)).quantize(cent) for k in keys) got = tuple(Decimal(str(root[k] or 0)).quantize(cent) for k in keys)
if got != tuple(v.quantize(cent) for v in osv.total_of(data)): if got != tuple(v.quantize(cent) for v in osv.total_of(data)):