From 94ff75fbb1a4e49d68eb8ba874d5d94907a94739 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 13 Nov 2026 00:30:31 +0400 Subject: [PATCH] =?UTF-8?q?synthgen:=20=D0=91=D0=91=20=D0=B8=20=D0=9E?= =?UTF-8?q?=D0=A4=D0=A0=20=D0=BF=D0=BE=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D1=83=201=D0=A1,=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20kind?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D1=85=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=BE=D0=BA=20(#3?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../specs/2026-11-12-synthgen-design.md | 5 + synthgen/economy.py | 20 +- synthgen/forms.py | 290 ++++++++++++++++++ synthgen/forms_xlsx.py | 109 +++++++ synthgen/ledger.py | 11 +- synthgen/templates/bb_1c.xlsx | Bin 0 -> 17409 bytes synthgen/templates/build_forms_templates.py | 82 +++++ synthgen/templates/ofr_1c.xlsx | Bin 0 -> 11947 bytes synthgen/tests/conftest.py | 18 ++ synthgen/tests/test_forms.py | 237 ++++++++++++++ synthgen/tests/test_forms_parser.py | 109 +++++++ synthgen/tests/test_forms_xlsx.py | 185 +++++++++++ synthgen/tests/test_ledger.py | 15 + 13 files changed, 1069 insertions(+), 12 deletions(-) create mode 100644 synthgen/forms.py create mode 100644 synthgen/forms_xlsx.py create mode 100644 synthgen/templates/bb_1c.xlsx create mode 100644 synthgen/templates/build_forms_templates.py create mode 100644 synthgen/templates/ofr_1c.xlsx create mode 100644 synthgen/tests/test_forms.py create mode 100644 synthgen/tests/test_forms_parser.py create mode 100644 synthgen/tests/test_forms_xlsx.py diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index c5e8c1d..406e605 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -296,3 +296,8 @@ README прогона, точные субсчета внутри перечис | 32 | 12.11.2026 | R-3 (рулинг контроллера по ревью Task 4): расхождение по займу — по основному долгу (правило 4 ДП §4), проценты 76.09 ↔ 76.09 начисляются симметрично и гасятся в месяце начисления, в расхождения не попадают; если у займодавца на конец периода денег меньше 2 × суммы транша, транш «в пути» проводит заёмщик. ВГО-выручка продавца за год ≤ 40 % его годовой выручки с НДС (`SELLER_CAP`, симметрично `BUYER_CAP`; пропорциональное сжатие) | расхождение в 76↔76 дало бы ядру правило 6 с меньшей стороной 0 (процент не определён, OQ-175); без потолка продавца ВГО-выручка могла превысить годовую выручку | при другом проценте ядра у займов оракул Task 5 расходится с генератором; потолок 40 % сжимает объём пар | | 33 | 12.11.2026 | Свёртка ОСВ (`osv.rows`) идёт по индексу книги с накопленными по дням итогами (счёт, субсчёт, контрагент), а не через `Ledger.balance/turnover` на каждый период; результат совпадает с ними (тест) | на каждый период и счёт проходить по всем проводкам книги слишком долго | нет: расхождение ловится `test_rows_match_ledger` | | 34 | 12.11.2026 | Строки ОСВ без сальдо и оборотов не пишутся; у счёта 80 без аналитики (головной) строка счёта без раскрытия; «Итого» — сумма строк счёта; колонка ИНН пишется только у контрагентов с ИНН | как в выгрузке 1С; ДП не требует нулевых строк | нет: парсер принимает, тест `-m parser` | +| 35 | 12.11.2026 | T3.8: закрывающие проводки различаются полем `Posting.kind` (`KIND_CLOSE_90` — 90.01..90.08 → 90.09, `KIND_CLOSE_99` — 90.09 и 91 → 99, `KIND_CLOSE_COST` — 26/44 → 90.08/90.07), memo остаётся подписью. В ОФР не входят `close90` и `close99`; закрытие затрат входит; обороты считаются как «нетто» Дт − Кт по счёту, а не только по одной стороне | разбор memo хрупок; закрытие на 90.09 удваивало бы обороты 90.01/90.02 | проводка без `kind` в закрывающем коде попадёт в ОФР: ловит `test_net_profit_equals_99_balance` | +| 36 | 12.11.2026 | Масштабированные колонки ББ (до начала книги): множитель 0.8–1.1 на строку из `rng_for(seed, "prev", код, вид, код компании, период, номер колонки)` (спека называла только код и вид — добавлены компания, период и колонка, чтобы колонки не совпадали между компаниями); баланс уравнивает строка 1370 (нераспределённая прибыль), а не ±1 подгонка | независимые множители ломают 1600 = 1700 на величину, не лечащуюся подгонкой ±1; прошлые колонки по спеке согласованности не требуют | 1370 прошлых колонок может выйти нетипичной (в т. ч. убыток) | +| 37 | 12.11.2026 | Подгонка округления ББ: ±1 тыс. строкам с наименьшей потерей точности, каждой строке не более одного раза; подогнанные строки записаны в `Form.plugged` (по колонкам) | «в одной строке» из §5.6 п. 3 не всегда хватает: при ~20 строках расхождение округлений достигает нескольких тыс. | строка подгонки может отличаться от книги на 1 тыс. (тест допускает ±1 только для `plugged`) | +| 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н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами | diff --git a/synthgen/economy.py b/synthgen/economy.py index b827131..1cc9ba2 100644 --- a/synthgen/economy.py +++ b/synthgen/economy.py @@ -23,7 +23,7 @@ import math from synthgen.config import Config from synthgen.counterparties import CounterpartyPlan from synthgen.group import Group, Member -from synthgen.ledger import CENT, An, Ledger, opening +from synthgen.ledger import CENT, KIND_CLOSE_90, KIND_CLOSE_99, KIND_CLOSE_COST, An, Ledger, opening from synthgen.periods import Period, book_end, book_start from synthgen.rng import rng_for @@ -368,30 +368,30 @@ def _close_all(lg: Ledger, day: date, prefix: str) -> Decimal: result = D(0) for (acct, an), bal in sorted(lg.balance(day, prefix).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): if bal > 0: - lg.post(day, "99", None, acct, an, bal, "закрытие на 99") + lg.post(day, "99", None, acct, an, bal, "закрытие на 99", KIND_CLOSE_99) else: - lg.post(day, acct, an, "99", None, -bal, "закрытие на 99") + lg.post(day, acct, an, "99", None, -bal, "закрытие на 99", KIND_CLOSE_99) result -= bal return result def _close_90(lg: Ledger, day: date) -> Decimal: - """Как в 1С: сальдо 90.01..90.08 переносятся на 90.09 (проводки с memo «закрытие на 90.09»), затем 90.09 — на 99 - (memo «закрытие на 99»). Обороты 90.09 — только закрывающие; свёртка ОФР различает их по memo.""" + """Как в 1С: сальдо 90.01..90.08 переносятся на 90.09 (проводки `KIND_CLOSE_90`), затем 90.09 — на 99 + (`KIND_CLOSE_99`). Обороты 90.09 — только закрывающие; свёртка ОФР различает их по `Posting.kind`.""" result = D(0) for (acct, an), bal in sorted(lg.balance(day, "90").items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): if acct == "90.09": continue if bal > 0: - lg.post(day, "90.09", None, acct, an, bal, "закрытие на 90.09") + lg.post(day, "90.09", None, acct, an, bal, "закрытие на 90.09", KIND_CLOSE_90) else: - lg.post(day, acct, an, "90.09", None, -bal, "закрытие на 90.09") + lg.post(day, acct, an, "90.09", None, -bal, "закрытие на 90.09", KIND_CLOSE_90) result -= bal bal09 = lg.balance(day, "90.09").get(("90.09", None), D(0)) if bal09 > 0: - lg.post(day, "99", None, "90.09", None, bal09, "закрытие на 99") + lg.post(day, "99", None, "90.09", None, bal09, "закрытие на 99", KIND_CLOSE_99) elif bal09 < 0: - lg.post(day, "90.09", None, "99", None, -bal09, "закрытие на 99") + lg.post(day, "90.09", None, "99", None, -bal09, "закрытие на 99", KIND_CLOSE_99) return result @@ -405,7 +405,7 @@ def close_month(ledgers: dict[str, Ledger], year: int, month: int) -> None: for cost, target in (("26", "90.08"), ("44", "90.07")): for (acct, an), bal in lg.balance(day, cost).items(): if bal > 0: - lg.post(day, target, None, acct, an, bal, "закрытие затрат") + lg.post(day, target, None, acct, an, bal, "закрытие затрат", KIND_CLOSE_COST) result = _close_90(lg, day) + _close_all(lg, day, "91") tax = q(result * PROFIT_TAX) if tax > 0: diff --git a/synthgen/forms.py b/synthgen/forms.py new file mode 100644 index 0000000..984a828 --- /dev/null +++ b/synthgen/forms.py @@ -0,0 +1,290 @@ +"""ББ и ОФР из книги проводок (спека §5.6 п. 3 и 6, §6.2): сальдо на даты и обороты за периоды -> строки форм 66н. + +Правила свёртки счетов в строки — из `synthetic-group/gen/forms.py` (#35) без вариантов FOOD/SERV/ФСБУ 4 и без намеренных +ошибок форм. Книга ведётся в рублях; `balance_rub` / `income_rub` отдают рубли по кодам строк, `balance` / `income` — +готовые формы в тыс. руб. целыми с `ROUND_HALF_UP` построчно, итоги — суммы округлённых строк; 1600 = 1700 обеспечивается +подгонкой ±1 тыс. строкам с наименьшей потерей точности (каждая строка — не более одного раза; такие строки записаны в +`Form.plugged`). Расходы ОФР в форме отрицательные. + +Колонка берётся из книги, если её дата (ББ) или период (ОФР) лежит внутри книги; иначе — масштабированные значения первой +(книжной) колонки со множителем 0.8–1.1 на строку (решение 5), а в ББ строка 1370 уравнивает баланс (решение 35). +Закрывающие проводки месяца (`KIND_CLOSE_90`, `KIND_CLOSE_99`) в ОФР не входят, закрытие затрат (`KIND_CLOSE_COST`) входит. +""" + +from dataclasses import dataclass, field +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +from synthgen.chart import is_off_balance +from synthgen.group import Member +from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, Ledger +from synthgen.periods import Period, book_end, book_start +from synthgen.rng import rng_for + +D = Decimal +ZERO = D(0) +ONE = D(1) +THOUSAND = D(1000) + +RECEIVABLE_ROOTS = ("60", "62", "68", "69", "70", "71", "73", "75", "76") # Дт-сальдо -> 1230, Кт-сальдо -> 1520 + +BALANCE_CODES: tuple[str, ...] = ( + "1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190", "1100", + "1210", "1220", "1230", "1240", "1250", "1260", "1200", "1600", + "1310", "1320", "1340", "1350", "1360", "1370", "1300", + "1410", "1420", "1430", "1450", "1400", + "1510", "1520", "1530", "1540", "1550", "1500", "1700", +) # fmt: skip +INCOME_CODES: tuple[str, ...] = ( + "2110", "2120", "2100", "2210", "2220", "2200", "2310", "2320", "2330", "2340", "2350", "2300", + "2410", "2411", "2412", "2460", "2400", "2510", "2520", "2530", "2500", "2900", "2910", +) # fmt: skip +SECTION_TOTALS: dict[str, tuple[str, ...]] = { + "1100": ("1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180", "1190"), + "1200": ("1210", "1220", "1230", "1240", "1250", "1260"), + "1300": ("1310", "1320", "1340", "1350", "1360", "1370"), + "1400": ("1410", "1420", "1430", "1450"), + "1500": ("1510", "1520", "1530", "1540", "1550"), +} +ASSET_LEAVES = SECTION_TOTALS["1100"] + SECTION_TOTALS["1200"] +LIABILITY_LEAVES = SECTION_TOTALS["1300"] + SECTION_TOTALS["1400"] + SECTION_TOTALS["1500"] +BALANCING_LEAF = "1370" # уравнивает баланс масштабированных колонок +INCOME_LEAVES = ("2110", "2120", "2210", "2220", "2310", "2320", "2330", "2340", "2350", "2410") # со знаком формы +SCALE_MIN, SCALE_MAX = 8000, 11000 # множитель 0.8–1.1 в десятитысячных + + +@dataclass(frozen=True) +class Form: + kind: str # BALANCE | INCOME + member: Member + period: Period + columns: list[str] # подписи колонок + values: dict[str, list[Decimal]] # код -> значения колонок, тыс. руб. целые; расходы ОФР отрицательные + dates: list[date] # ББ: даты колонок; ОФР: конец периода каждой колонки + from_book: list[bool] # колонка получена из книги (иначе масштабирована) + plugged: list[tuple[str, ...]] = field(default_factory=list) # ББ: строки, подогнанные ±1 в каждой колонке + + +# ---------------------------------------------------------------- рубли + +def _classify(acct: str, bal: Decimal, member: Member) -> tuple[str, Decimal]: + """(код строки, сумма со знаком строки) для одного сальдо (Дт > 0).""" + root = acct.split(".")[0] + if root in ("01", "02", "03"): + return "1150", bal + if root == "08": + return "1190", bal + if root in ("10", "20", "26", "41", "43", "44"): + return "1210", bal + if root == "97": + return "1260", bal + if root == "19": + return "1220", bal + if root in ("50", "51", "52", "55"): + return "1250", bal + if acct == "58.01": + return "1170", bal + if acct == "58.03": + return ("1170" if member.is_head else "1240"), bal # займы головной — на срок > 12 мес. + if root in RECEIVABLE_ROOTS: + if acct == "76.07.1" and bal < 0: + return "1450", -bal # арендные обязательства считаем долгосрочными + if bal > 0: + return "1230", bal + return "1520", -bal + if root in ("66", "67") and bal < 0: + return ("1510" if root == "66" else "1410"), -bal + if root in ("80", "84", "99", "82", "83") and bal != 0: + return {"80": "1310", "82": "1360", "83": "1350"}.get(root, "1370"), -bal + if root == "96": + return "1540", -bal + raise ValueError(f"{member.code}: нет правила строки ББ для {acct} (сальдо {bal})") + + +def _balance_leaves(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]: + 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]))): + if acct == "000" or is_off_balance(acct): + continue + code, amount = _classify(acct, bal, member) + leaves[code] += amount + return leaves + + +def _add_totals(values: dict[str, Decimal]) -> dict[str, Decimal]: + out = dict(values) + for total, parts in SECTION_TOTALS.items(): + out[total] = sum((out[c] for c in parts), ZERO) + out["1600"] = out["1100"] + out["1200"] + out["1700"] = out["1300"] + out["1400"] + out["1500"] + return out + + +def balance_rub(ledger: Ledger, at: date, member: Member) -> dict[str, Decimal]: + """Код строки -> рубли (спека §5.6 п. 3): сальдо по строке, 000 и забалансовые не входят.""" + full = _add_totals(_balance_leaves(ledger, at, member)) + if full["1600"] != full["1700"]: + raise ValueError(f"{ledger.company} на {at}: актив {full['1600']} != пассив {full['1700']}") + return {c: full[c] for c in BALANCE_CODES} + + +def _monthly_income(ledger: Ledger) -> dict[tuple[int, int], dict[str, Decimal]]: + """Обороты 90/91/99 по месяцам, рубли со знаком формы (доходы +, расходы −, 2410 = −налог); кэш в книге.""" + cached = ledger._cache.get("forms_income") + if cached is not None: + return cached + out: dict[tuple[int, int], dict[str, Decimal]] = {} + for p in ledger.postings: + if p.kind in (KIND_CLOSE_90, KIND_CLOSE_99): + continue + row = out.setdefault((p.day.year, p.day.month), {}) + for acct, an, sign in ((p.dt, p.dt_an, 1), (p.ct, p.ct_an, -1)): # Дт +, Кт −; в строку идёт −сальдо + item = an.item if an else None + code = {"90.01": "2110", "90.03": "2110", "90.02": "2120", "90.07": "2210", "90.08": "2220"}.get(acct) + if acct == "91.01": + code = {"Дивиденды": "2310", "Проценты": "2320"}.get(item, "2340") + elif acct == "91.02": + code = "2330" if item == "Проценты" else "2350" + if code: + row[code] = row.get(code, ZERO) - sign * p.amount + if p.dt == "99" and p.ct == "68.04": # налог на прибыль: расход — минус, корректировка — плюс + row["2410"] = row.get("2410", ZERO) - p.amount + elif p.dt == "68.04" and p.ct == "99": + row["2410"] = row.get("2410", ZERO) + p.amount + ledger._cache["forms_income"] = out + return out + + +def income_rub(ledger: Ledger, start: date, end: date) -> dict[str, Decimal]: + """Строки-листья ОФР за период в рублях со знаком формы (расходы отрицательные).""" + if (start.month, start.day) != (1, 1) or (end + date.resolution).day != 1: + raise ValueError(f"период {start}..{end} не выровнен по месяцам") + monthly = _monthly_income(ledger) + res = {c: ZERO for c in INCOME_LEAVES} + y, m = start.year, start.month + while (y, m) <= (end.year, end.month): + for code, v in monthly.get((y, m), {}).items(): + res[code] += v + y, m = (y + 1, 1) if m == 12 else (y, m + 1) + return res + + +# ---------------------------------------------------------------- единицы формы + +def _thousands(x: Decimal) -> Decimal: + return (x / THOUSAND).quantize(ONE, rounding=ROUND_HALF_UP) + + +def _plug(rub: dict[str, Decimal]) -> tuple[dict[str, Decimal], tuple[str, ...]]: + """Округление листьев ББ и подгонка так, чтобы актив и пассив сошлись (как «бухгалтерская» подгонка 1С): ±1 тыс. строкам + с наименьшей потерей точности среди строк с ненулевой суммой, каждой строке — не более одного раза.""" + out = {c: _thousands(rub[c]) for c in ASSET_LEAVES + LIABILITY_LEAVES} + diff = sum((out[c] for c in ASSET_LEAVES), ZERO) - sum((out[c] for c in LIABILITY_LEAVES), ZERO) + if diff == 0: + return out, () + step = -ONE if diff > 0 else ONE # шаг строке актива; пассиву — наоборот + cands = [] + for c in ASSET_LEAVES + LIABILITY_LEAVES: + if rub[c] == 0: + continue + delta = step if c in ASSET_LEAVES else -step + cands.append((abs(out[c] + delta - rub[c] / THOUSAND), c, delta)) + cands.sort() + need = int(abs(diff)) + if need > len(cands): + raise ValueError(f"нечего подгонять: расхождение {diff} тыс., строк {len(cands)}") + for _, c, delta in cands[:need]: + out[c] += delta + return out, tuple(sorted(c for _, c, _ in cands[:need])) + + +def _scale(rng_keys: tuple, code: str, value: Decimal, seed: int, kind: str) -> Decimal: + factor = D(rng_for(seed, "prev", code, kind, *rng_keys).randint(SCALE_MIN, SCALE_MAX)) / D(10000) + return value * factor + + +# ---------------------------------------------------------------- формы + +_MONTHS_WORDS = {1: "3 месяца", 2: "6 месяцев", 3: "9 месяцев"} + + +def balance_dates(period: Period) -> list[date]: + return [period.end, date(period.year - 1, 12, 31), date(period.year - 2, 12, 31)] + + +def income_ends(period: Period) -> list[date]: + return [period.end, date(period.year - 1, period.end.month, period.end.day)] + + +def _balance_titles(period: Period) -> list[str]: + first = f"На 31 декабря\n{period.year} года" if period.quarter == 4 else f"За {_MONTHS_WORDS[period.quarter]}\n{period.year} года" + return [first, f"На 31 декабря\n{period.year - 1} года", f"На 31 декабря\n{period.year - 2} года"] + + +def _income_titles(period: Period) -> list[str]: + if period.quarter == 4: + return [f"За {period.year} год", f"За {period.year - 1} год"] + words = _MONTHS_WORDS[period.quarter] + return [f"За {words}\n{period.year} года", f"За {words}\n{period.year - 1} года"] + + +def balance(ledger: Ledger, member: Member, period: Period, ps: list[Period], seed: int) -> Form: + """ББ за период: на конец периода, на 31.12 прошлого года и позапрошлого (в книге — из книги, иначе масштаб).""" + dates = balance_dates(period) + lo, hi = book_start(ps), book_end(ps) + cols: list[dict[str, Decimal]] = [] + from_book: list[bool] = [] + plugged: list[tuple[str, ...]] = [] + base_rub: dict[str, Decimal] | None = None + for i, day in enumerate(dates): + if lo <= day <= hi: + rub = balance_rub(ledger, day, member) + base_rub = base_rub or rub + values, plug = _plug(rub) + from_book.append(True) + else: + assert base_rub is not None, "первая колонка ББ всегда в книге" + scaled = {c: _scale((member.code, period.key, i), c, base_rub[c], seed, "BALANCE") for c in ASSET_LEAVES + LIABILITY_LEAVES} + others = sum((scaled[c] for c in LIABILITY_LEAVES if c != BALANCING_LEAF), ZERO) + scaled[BALANCING_LEAF] = sum((scaled[c] for c in ASSET_LEAVES), ZERO) - others + values, plug = _plug(scaled) + from_book.append(False) + cols.append(_add_totals(values)) + plugged.append(plug) + return Form( + "BALANCE", member, period, _balance_titles(period), {c: [col[c] for col in cols] for c in BALANCE_CODES}, dates, + from_book, plugged, + ) # fmt: skip + + +def _income_column(leaves: dict[str, Decimal]) -> dict[str, Decimal]: + r = {c: _thousands(leaves[c]) for c in INCOME_LEAVES} + r["2100"] = r["2110"] + r["2120"] + r["2200"] = r["2100"] + r["2210"] + r["2220"] + r["2300"] = r["2200"] + r["2310"] + r["2320"] + r["2330"] + r["2340"] + r["2350"] + r["2411"] = r["2410"] # налог — текущий; отложенного в модели нет + r["2412"] = r["2460"] = r["2510"] = r["2520"] = r["2530"] = r["2900"] = r["2910"] = ZERO + r["2400"] = r["2300"] + r["2410"] + r["2460"] + r["2500"] = r["2400"] + r["2510"] + r["2520"] + r["2530"] + return r + + +def income(ledger: Ledger, member: Member, period: Period, ps: list[Period], seed: int) -> Form: + """ОФР за период и за тот же период прошлого года (в книге — из книги, иначе масштаб первой колонки).""" + ends = income_ends(period) + lo, hi = date(ps[0].year, 1, 1), book_end(ps) + cols: list[dict[str, Decimal]] = [] + from_book: list[bool] = [] + base: dict[str, Decimal] | None = None + for i, end in enumerate(ends): + start = date(end.year, 1, 1) + if lo <= start and end <= hi: + leaves = income_rub(ledger, start, end) + base = base or leaves + from_book.append(True) + else: + assert base is not None, "первая колонка ОФР всегда в книге" + leaves = {c: _scale((member.code, period.key, i), c, base[c], seed, "INCOME") for c in INCOME_LEAVES} + from_book.append(False) + cols.append(_income_column(leaves)) + return Form("INCOME", member, period, _income_titles(period), {c: [col[c] for col in cols] for c in INCOME_CODES}, ends, from_book) diff --git a/synthgen/forms_xlsx.py b/synthgen/forms_xlsx.py new file mode 100644 index 0000000..d5cc3f0 --- /dev/null +++ b/synthgen/forms_xlsx.py @@ -0,0 +1,109 @@ +"""ББ и ОФР в Excel по шаблону 1С (спека §6.2): копия книги образца (`templates/bb_1c.xlsx`, `ofr_1c.xlsx`) с подстановкой +реквизитов и значений. Значения ищутся по колонке «Коды» (и номерам колонок под заголовком), а не по номерам строк; +положение реквизитов шапки — таблица `HEADER_CELLS` (шаблон наш, собран `templates/build_forms_templates.py`). + +Значения — целые тыс. руб., расходы ОФР — отрицательные числа (решение 12); ноль пишется `0` с форматом `[=0]"-";General`, +остальное — `#,##0`, как в выгрузке 1С. +""" + +from pathlib import Path + +import openpyxl + +from synthgen import xlsx +from synthgen.forms import Form +from synthgen.rng import stable_hash + +TEMPLATES = Path(__file__).with_name("templates") +TEMPLATE_FILES = {"BALANCE": "bb_1c.xlsx", "INCOME": "ofr_1c.xlsx"} +ZERO_FORMAT = '[=0]"-";General' +NUMBER_FORMAT = "#,##0" +HEADER_CELLS = { + "BALANCE": { + "title": "B3", "org": "G7", "okpo": "R7", "inn": "R8", "activity": "G9", "okved": "R9", "legal_form": "G10", "okopf": "R10", + "okfs": "S10", "address": "G13", "date": ("R6", "S6", "T6"), "head": "Q80", "accountant": "Q83", + }, + "INCOME": { + "title": "B3", "org": "G7", "okpo": "P7", "inn": "P8", "activity": "G9", "okved": "P9", "legal_form": "G10", "okopf": "P10", + "okfs": "Q10", "address": None, "date": ("P6", "Q6", "R6"), "head": "O49", "accountant": "O52", + }, +} # fmt: skip +ACTIVITY = { + "агро": ("Сельское хозяйство", "01.11.11"), "торговля": ("Торговля оптовая", "46.90"), + "логистика": ("Деятельность автомобильного грузового транспорта", "49.41"), "производство": ("Производство прочих изделий", "25.99"), + "строительство": ("Строительство зданий", "41.20"), "услуги": ("Деятельность консультативная", "70.22"), +} # fmt: skip +LEGAL_FORM = {"ООО": ("ООО - общество с ограниченной ответственностью", "65"), "АО": ("АО - акционерное общество", "47")} +STREETS = ("Ленина", "Советская", "Мира", "Заводская", "Промышленная", "Центральная", "Садовая", "Новая") +CITIES = ("г. Тверь", "г. Липецк", "г. Курск", "г. Тула", "г. Рязань", "г. Орёл", "г. Тамбов", "г. Калуга") +SURNAMES = ("Ветров", "Озеров", "Лугов", "Дубравин", "Ключников", "Ракитин", "Соловьёв", "Березин", "Туманов", "Зорин") +FEMALE_SURNAMES = ("Ветрова", "Озерова", "Лугова", "Дубравина", "Ключникова", "Ракитина", "Соловьёва", "Березина", "Туманова", "Зорина") +MALE_NAMES = ("Андрей Павлович", "Игорь Сергеевич", "Олег Николаевич", "Максим Андреевич", "Денис Олегович") +FEMALE_NAMES = ("Анна Игоревна", "Ольга Павловна", "Елена Сергеевна", "Марина Андреевна", "Татьяна Олеговна") + + +def file_name(form: Form) -> str: + prefix = "ББ" if form.kind == "BALANCE" else "ОФР" + return f"{prefix}_{form.period.label_1c[:-1]}.xlsx" # метка периода кончается точкой — она же перед расширением + + +def _person(form: Form, role: str) -> str: + key = f"{form.member.code}|{form.period.key}|{role}" + if stable_hash(key + "|sex", 2): + return f"{FEMALE_SURNAMES[stable_hash(key + '|s', 10)]} {FEMALE_NAMES[stable_hash(key + '|n', 5)]}" + return f"{SURNAMES[stable_hash(key + '|s', 10)]} {MALE_NAMES[stable_hash(key + '|n', 5)]}" + + +def _requisites(form: Form) -> dict[str, object]: + m = form.member + activity, okved = ACTIVITY.get(m.org.kind, ACTIVITY["услуги"]) + legal, okopf = LEGAL_FORM.get(m.org.form, LEGAL_FORM["АО"]) + h = stable_hash(m.inn, 10**6) + address = f"{100000 + stable_hash(m.inn + 'z', 899999)}, {CITIES[h % 8]}, ул. {STREETS[(h // 8) % 8]}, дом № {1 + h % 97}" + end = form.period.end + return { + "title": f"за {form.period.label_1c}", "org": m.name, "okpo": f"{stable_hash(m.inn + 'okpo', 10**8):08d}", "inn": m.inn, + "activity": activity, "okved": okved, "legal_form": legal, "okopf": okopf, "okfs": "16", "address": address, + "head": _person(form, "head"), "accountant": _person(form, "accountant"), "date": (end.day, end.month, end.year), + } # fmt: skip + + +def _tables(ws) -> list[tuple[int, list[int], int]]: + """Таблицы формы по заголовку «Коды»: (колонка кодов, колонки значений, строка заголовка).""" + found = [] + for row in ws.iter_rows(): + for cell in row: + if cell.value == "Коды": + cols = [c.column for c in ws[cell.row + 1] if c.value is not None and c.column > cell.column] + found.append((cell.column, cols, cell.row)) + return found + + +def write(form: Form, path: Path) -> None: + wb = openpyxl.load_workbook(TEMPLATES / TEMPLATE_FILES[form.kind]) + ws = wb.active + cells = HEADER_CELLS[form.kind] + req = _requisites(form) + for key, coord in cells.items(): + if coord is None: + continue + if key == "date": + for c, v in zip(coord, req["date"], strict=True): + ws[c].value = v + else: + ws[coord].value = req[key] + for code_col, value_cols, head in _tables(ws): + if len(value_cols) != len(form.columns): + raise ValueError(f"в шаблоне {len(value_cols)} колонок значений, в форме {len(form.columns)}") + for col, title in zip(value_cols, form.columns, strict=True): + ws.cell(head, col).value = title + for r in range(head + 2, ws.max_row + 1): + code = ws.cell(r, code_col).value + if not (isinstance(code, str) and code.isdigit() and len(code) == 4): + continue + for i, col in enumerate(value_cols): + value = int(form.values[code][i]) + cell = ws.cell(r, col) + cell.value = value + cell.number_format = ZERO_FORMAT if value == 0 else NUMBER_FORMAT + xlsx.save_deterministic(wb, path) diff --git a/synthgen/ledger.py b/synthgen/ledger.py index eeb4859..0e76da2 100644 --- a/synthgen/ledger.py +++ b/synthgen/ledger.py @@ -8,6 +8,12 @@ from pathlib import Path CENT = Decimal("0.01") +# Тип проводки (решение 35): закрывающие проводки помечены явно, свёртка ОФР не разбирает memo. +KIND_ORDINARY = "" # обычная операция +KIND_CLOSE_90 = "close90" # перенос сальдо 90.01..90.08 на 90.09 — в ОФР не входит +KIND_CLOSE_99 = "close99" # закрытие 90.09 и 91 на 99 — в ОФР не входит +KIND_CLOSE_COST = "close_cost" # закрытие 26/44 на 90.08/90.07 — это расходы периода, входит в ОФР + @dataclass(frozen=True, slots=True) class An: @@ -27,6 +33,7 @@ class Posting: ct_an: An | None amount: Decimal memo: str + kind: str = KIND_ORDINARY Key = tuple[str, An | None] @@ -43,10 +50,10 @@ class Ledger: self._maxday: date | None = None self.meta: dict = {} # произвольные данные модели компании (используются economy) - def post(self, day, dt, dt_an, ct, ct_an, amount, memo) -> None: + def post(self, day, dt, dt_an, ct, ct_an, amount, memo, kind: str = KIND_ORDINARY) -> None: if amount <= 0 or amount != amount.quantize(CENT): raise ValueError(f"сумма должна быть > 0 с двумя знаками: {amount}") - self.postings.append(Posting(day, dt, dt_an, ct, ct_an, amount, memo)) + self.postings.append(Posting(day, dt, dt_an, ct, ct_an, amount, memo, kind)) self._cache.clear() kd, kc = (dt, dt_an), (ct, ct_an) self._tot[kd] = self._tot.get(kd, Decimal(0)) + amount diff --git a/synthgen/templates/bb_1c.xlsx b/synthgen/templates/bb_1c.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7b320197e093cd81b53cc302708de185b0942ea2 GIT binary patch literal 17409 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 bool: + return isinstance(value, str) and value.isdigit() and len(value) == 4 + + +def build(kind: str, samples: Path = SAMPLES, out_dir: Path = HERE) -> Path: + sample, name, last_col, code_col, clear = BUILD[kind] + wb = openpyxl.load_workbook(samples / sample) + ws = wb.active + value_cols: set[int] = set() + for row in ws.iter_rows(): + for cell in row: # колонки значений: ячейки номеров колонок (строка под «Коды») правее колонки кодов + if cell.value == "Коды" and cell.column == code_col: + value_cols |= {c.column for c in ws[cell.row + 1] if c.value is not None and c.column > code_col} + code_rows = [r for r in range(1, ws.max_row + 1) if _is_code(ws.cell(r, code_col).value)] + for coord in clear: + ws[coord].value = None + for r in code_rows: + for c in value_cols: + cell = ws.cell(r, c) + if not isinstance(cell, MergedCell): + cell.value = None + for row in ws.iter_rows(): + for cell in row: + if isinstance(cell.value, str) and cell.value.startswith("="): + cell.value = None + if cell.column > last_col and not isinstance(cell, MergedCell): + cell.value = None + cell.font, cell.fill, cell.border, cell.alignment = Font(), PatternFill(), Border(), Alignment() + cell.number_format = "General" + wb.properties.title = None + path = out_dir / name + xlsx.save_deterministic(wb, path) + return path + + +def main(argv: list[str]) -> None: + samples = Path(argv[1]) if len(argv) > 1 else SAMPLES + for kind in BUILD: + print(build(kind, samples)) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/synthgen/templates/ofr_1c.xlsx b/synthgen/templates/ofr_1c.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6809e3193486c43c1dfd15a468c6a2196be42527 GIT binary patch literal 11947 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 Decimal: + return (x / 1000).quantize(Decimal(1), rounding=ROUND_HALF_UP) + + +@pytest.fixture(scope="module") +def built(forms_world): + cfg, group, periods, books = forms_world + out = [] + for m in group.members: + for p in periods: + out.append((m, p, forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed))) + return out + + +def test_codes(): + assert forms.BALANCE_CODES[0] == "1110" and forms.BALANCE_CODES[-1] == "1700" and "1215" not in forms.BALANCE_CODES + assert forms.INCOME_CODES[-3:] == ("2500", "2900", "2910") + assert {"2530", "2900", "2910", "2411", "2412", "2460"} <= set(forms.INCOME_CODES) + + +def test_form_shape(built): + for m, p, bal, inc in built: + assert bal.kind == "BALANCE" and inc.kind == "INCOME" and bal.member is m and bal.period is p + assert len(bal.columns) == 3 and len(inc.columns) == 2 + assert set(bal.values) == set(forms.BALANCE_CODES) and set(inc.values) == set(forms.INCOME_CODES) + assert all(len(v) == 3 for v in bal.values.values()) and all(len(v) == 2 for v in inc.values.values()) + assert all(x == x.to_integral_value() for v in bal.values.values() for x in v), "тыс. руб. целыми" + + +def test_balance_identity(built): + for m, p, bal, _ in built: + for col in range(3): + v = {c: bal.values[c][col] for c in forms.BALANCE_CODES} + for total, parts in SECTIONS.items(): + assert v[total] == sum((v[c] for c in parts), ZERO), (m.code, p.key, col, total) + assert v["1600"] == v["1100"] + v["1200"] + assert v["1700"] == v["1300"] + v["1400"] + v["1500"] + assert v["1600"] == v["1700"], (m.code, p.key, col) + + +def test_balance_matches_ledger(built, forms_world): + _, _, _, books = forms_world + adjusted = 0 + for m, p, bal, _ in built: + rub = forms.balance_rub(books[m.code], p.end, m) + for code in forms.BALANCE_CODES: + if code in SECTIONS or code in ("1600", "1700"): + continue + want = _thousands(rub[code]) + got = bal.values[code][0] + if code in bal.plugged[0]: + assert abs(got - want) == 1, (m.code, p.key, code) + adjusted += 1 + else: + 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 adjusted >= 0 + + +def test_plug_is_minimal_and_recorded(built): + for _, p, bal, _ in built: + assert all(len(set(cols)) == len(cols) for cols in bal.plugged) + + +def test_first_prior_column_from_book(built, forms_world): + _, _, periods, books = forms_world + first = periods[0] + day = book_start(periods) + assert day == date(first.year - 1, 12, 31) + for m, p, bal, _ in built: + if p.year != first.year: + continue + rub = forms.balance_rub(books[m.code], day, m) + for code in forms.BALANCE_CODES: + if code in SECTIONS or code in ("1600", "1700"): + continue + if code in bal.plugged[1]: + assert abs(bal.values[code][1] - _thousands(rub[code])) == 1 + else: + assert bal.values[code][1] == _thousands(rub[code]), (m.code, p.key, code) + assert bal.from_book[1] and not bal.from_book[2] + + +def test_columns_inside_book_come_from_book(built, forms_world): + _, _, periods, books = forms_world + last = periods[-1] # 2026-Q1: 31.12.2025 и 31.12.2024 лежат в книге + for m, p, bal, _ in built: + if p is not last: + continue + assert bal.from_book == [True, True, True] + for col, day in enumerate((p.end, date(2025, 12, 31), date(2024, 12, 31))): + rub = forms.balance_rub(books[m.code], day, m) + for code in ("1150", "1230", "1250", "1520", "1370"): + assert abs(bal.values[code][col] - _thousands(rub[code])) <= 1, (m.code, col, code) + + +def test_scaled_columns(built): + for m, p, bal, _ in built: + if bal.from_book[2]: + continue + for code in ("1150", "1210", "1230", "1250", "1410", "1520"): + base, got = bal.values[code][0], bal.values[code][2] + if base == 0: + assert got == 0 + else: + assert base * Decimal("0.8") - 1 <= got <= base * Decimal("1.1") + 1, (m.code, p.key, code, base, got) + + +def test_forms_deterministic(forms_world, built): + cfg, group, periods, books = forms_world + m, p = group.members[0], periods[0] + again = forms.balance(books[m.code], m, p, periods, cfg.seed) + assert again.values == next(b for mm, pp, b, _ in built if mm is m and pp is p).values + other = forms.balance(books[m.code], m, p, periods, cfg.seed + 1) + assert other.values["1600"][0] == again.values["1600"][0] and other.values != again.values + + +def _income_expected(ledger, start, end): + net: dict[tuple[str, str | None], Decimal] = defaultdict(Decimal) + tax = ZERO + for pst in ledger.postings: + if not start <= pst.day <= end or pst.kind in (KIND_CLOSE_90, KIND_CLOSE_99): + continue + for acct, an, sign in ((pst.dt, pst.dt_an, 1), (pst.ct, pst.ct_an, -1)): + item = an.item if an else None + if acct.startswith("90") or acct.startswith("91"): + net[(acct, item if item in ("Дивиденды", "Проценты") else None)] += sign * pst.amount + if pst.dt == "99" and pst.ct == "68.04": + tax += pst.amount + elif pst.dt == "68.04" and pst.ct == "99": + tax -= pst.amount + g = lambda a, i=None: net.get((a, i), ZERO) # noqa: E731 + return { + "2110": -g("90.01") - g("90.03"), "2120": -g("90.02"), "2210": -g("90.07"), "2220": -g("90.08"), + "2310": -g("91.01", "Дивиденды"), "2320": -g("91.01", "Проценты"), "2330": -g("91.02", "Проценты"), + "2340": -g("91.01"), "2350": -g("91.02"), "2410": -tax, + } # fmt: skip + + +def test_income_matches_ledger(built, forms_world): + _, _, _, books = forms_world + nonzero = set() + for m, p, _, inc in built: + exp = _income_expected(books[m.code], p.start, p.end) + for code, rub in exp.items(): + assert inc.values[code][0] == _thousands(rub), (m.code, p.key, code, inc.values[code][0], rub) + if rub: + nonzero.add(code) + assert {"2110", "2120", "2220", "2410"} <= nonzero # прочих доходов/расходов (2340/2350) экономика не создаёт + + +def test_income_signs(built): + for _, _, _, inc in built: + for code in ("2120", "2210", "2220", "2330", "2350"): + assert all(x <= 0 for x in inc.values[code]), code + for code in ("2110", "2310", "2320", "2340"): + assert all(x >= 0 for x in inc.values[code]), code + + +def test_2400_flow(built): + for m, p, _, inc in built: + for col in range(2): + v = {c: inc.values[c][col] for c in forms.INCOME_CODES} + assert v["2400"] == v["2300"] + v["2410"] + v["2460"], (m.code, p.key, col) + assert v["2100"] == v["2110"] + v["2120"] + assert v["2200"] == v["2100"] + v["2210"] + v["2220"] + assert v["2300"] == v["2200"] + v["2310"] + v["2320"] + v["2330"] + v["2340"] + v["2350"] + assert v["2410"] == v["2411"] + v["2412"] + assert v["2500"] == v["2400"] + v["2510"] + v["2520"] + v["2530"] + assert v["2900"] == 0 and v["2910"] == 0 and v["2460"] == 0 + + +def test_net_profit_equals_99_balance(built, forms_world): + """2400 — тот же результат, что копится на 99 (до реформации в декабре); закрытия 90.09/99 в ОФР не задваивают.""" + _, _, _, books = forms_world + checked = 0 + for m, p, _, inc in built: + if p.quarter == 4: + continue + bal99 = books[m.code].balance(p.end, "99").get(("99", None), ZERO) + assert abs(inc.values["2400"][0] * 1000 + bal99) <= 5000, (m.code, p.key) + checked += 1 + assert checked + + +def test_prior_year_column_from_book(built, forms_world): + _, _, periods, books = forms_world + last = periods[-1] # 2026-Q1: прошлогодний 1 кв. 2025 лежит в книге + for m, p, _, inc in built: + if p is not last: + continue + assert inc.from_book == [True, True] + exp = _income_expected(books[m.code], date(2025, 1, 1), date(2025, 3, 31)) + for code, rub in exp.items(): + assert inc.values[code][1] == _thousands(rub), (m.code, code) + + +def test_prior_year_column_scaled(built): + for m, p, _, inc in built: + if inc.from_book[1]: + continue + for code in ("2110", "2120", "2220"): + base, got = inc.values[code][0], inc.values[code][1] + lo, hi = sorted((base * Decimal("0.8"), base * Decimal("1.1"))) + assert lo - 1 <= got <= hi + 1, (m.code, p.key, code, base, got) + + +def test_column_labels(built): + by = {p.key: (bal, inc) for m, p, bal, inc in built if m.code == built[0][0].code} + bal, inc = by["2025-Q3"] + assert bal.columns == ["За 9 месяцев\n2025 года", "На 31 декабря\n2024 года", "На 31 декабря\n2023 года"] + assert inc.columns == ["За 9 месяцев\n2025 года", "За 9 месяцев\n2024 года"] + bal, inc = by["2025-FY"] + assert bal.columns[0] == "На 31 декабря\n2025 года" and inc.columns == ["За 2025 год", "За 2024 год"] + assert by["2025-Q1"][1].columns[0] == "За 3 месяца\n2025 года" + assert by["2025-Q2"][1].columns[1] == "За 6 месяцев\n2024 года" diff --git a/synthgen/tests/test_forms_parser.py b/synthgen/tests/test_forms_parser.py new file mode 100644 index 0000000..167134f --- /dev/null +++ b/synthgen/tests/test_forms_parser.py @@ -0,0 +1,109 @@ +"""Приёмка ББ и ОФР парсером: CLI tabular-parser из зеркального worktree (`TP_WT`), маркер `parser`. + +Запуск: `$UVRUN python -m pytest synthgen/tests -q -m parser`. Без окружения тесты пропускаются. +""" + +import json +import os +import subprocess +from concurrent.futures import ThreadPoolExecutor +from decimal import Decimal +from pathlib import Path + +import pytest + +from synthgen import forms, forms_xlsx + +pytestmark = pytest.mark.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" + + +def _run(path: Path) -> dict: + env = {**os.environ, "ENV_FILE": str(TP_WT / "api" / ".env.test"), "PYTHONPATH": str(TP_WT / "api")} + proc = subprocess.run( + [str(PY), "-m", "src.parser.cli", str(path)], cwd=TP_WT / "api", env=env, capture_output=True, text=True, check=False + ) + assert proc.stdout.strip(), proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def parsed(forms_world, tmp_path_factory): + if not PY.exists(): + pytest.skip(f"нет окружения парсера: {PY}") + cfg, group, periods, books = forms_world + root = tmp_path_factory.mktemp("statements") + items = [] + for m in group.members: + for p in periods: + for f in (forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed)): + path = root / f"{m.code}_{p.key}_{forms_xlsx.file_name(f)}" + forms_xlsx.write(f, path) + items.append((f, path)) + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda fp: _run(fp[1]), items)) + return [(f, p, r) for (f, p), r in zip(items, results, strict=True)] + + +def _doc(res): + assert len(res["documents"]) == 1, [d["document_type"] for d in res["documents"]] + return res["documents"][0] + + +def _issues(res): + return [(d["status"], d["checks"], [w["code"] for w in d["warnings"]]) for d in res.get("documents", [])] + + +def test_parser_status_and_type(parsed): + bad = [] + for f, path, res in parsed: + docs = res.get("documents", []) + if res.get("status") != "done" or len(docs) != 1 or docs[0]["document_type"] != f.kind: + bad.append((path.name, res.get("status"), res.get("error"), _issues(res))) + assert not bad, bad[:5] + + +def test_parser_columns_and_units(parsed): + bad = [] + for f, path, res in parsed: + doc = _doc(res) + got = [c["period_date"] for c in sorted(doc["columns"], key=lambda c: c["column_index"])] + want = [d.isoformat() for d in f.dates] + if got != want: + bad.append((path.name, got, want)) + if "тыс" not in (doc["header"].get("units") or "").lower(): + bad.append((path.name, "units", doc["header"].get("units"))) + assert not bad, bad[:5] + + +def test_parser_checks_clean(parsed): + bad = [(path.name, _doc(res)["checks"]) for f, path, res in parsed if _doc(res)["checks"]["relations_failed"]] + assert not bad, bad[:5] + + +def test_parser_values_equal_form_values_and_balance(parsed): + bad = [] + for f, path, res in parsed: + doc = _doc(res) + order = [c["column_index"] for c in sorted(doc["columns"], key=lambda c: c["column_index"])] + got: dict[str, list[Decimal]] = {} + for line in doc["lines"]: + if line["line_status"] != "ok": + continue + row = got.setdefault(line["line_code"], [Decimal(0)] * len(order)) + for v in line["values"]: + if v["amount"] is not None: + row[order.index(v["column_index"])] += Decimal(str(v["amount"])) + codes = forms.BALANCE_CODES if f.kind == "BALANCE" else forms.INCOME_CODES + for code in codes: + for col, want in enumerate(f.values[code]): + have = got.get(code, [Decimal(0)] * len(order))[col] + if have != want: + bad.append((path.name, code, col, str(have), str(want))) + if f.kind == "BALANCE": + for col in range(3): + if got["1600"][col] != got["1700"][col]: + bad.append((path.name, "1600!=1700", col)) + assert not bad, bad[:10] diff --git a/synthgen/tests/test_forms_xlsx.py b/synthgen/tests/test_forms_xlsx.py new file mode 100644 index 0000000..40c5eac --- /dev/null +++ b/synthgen/tests/test_forms_xlsx.py @@ -0,0 +1,185 @@ +import zipfile +from decimal import Decimal +from pathlib import Path + +import openpyxl +import pytest + +from synthgen import forms, forms_xlsx +from synthgen.periods import build_periods + +TEMPLATES = Path(forms_xlsx.__file__).parent / "templates" +SAMPLE_DIR = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/statements-xlsx/mezheninovskaya/2025-Q3") +FORBIDDEN = ["7016006635", "85262693", "Межениновск", "МЕЖЕНИНОВСК", "Томск", "Шегарск", "Косарев", "Лушников", "Агрофирм", "АГРОФИРМ"] +ZERO_FORMAT = '[=0]"-";General' + + +@pytest.fixture(scope="module") +def sample_forms(forms_world, tmp_path_factory): + cfg, group, periods, books = forms_world + root = tmp_path_factory.mktemp("forms") + out = [] + for m in group.members[:2]: + for p in (periods[0], periods[2], periods[3]): + for f in (forms.balance(books[m.code], m, p, periods, cfg.seed), forms.income(books[m.code], m, p, periods, cfg.seed)): + path = root / f"{m.code}_{p.key}_{forms_xlsx.file_name(f)}" + forms_xlsx.write(f, path) + out.append((f, path)) + return out + + +def _raw_strings(path: Path) -> str: + with zipfile.ZipFile(path) as z: + return "\n".join(z.read(n).decode("utf-8", "ignore") for n in z.namelist()) + + +def _find_table(ws): + """Все таблицы форм по заголовку «Коды»: [(колонка кодов, [колонки значений], {код: строка})].""" + tables = [] + for row in ws.iter_rows(): + for c in row: + if c.value == "Коды": + cols = [x.column for x in ws[c.row + 1] if x.value is not None and x.column > c.column] + tables.append((c.column, cols, c.row)) + return tables + + +def _read_lines(ws): + """код -> [значения колонок] по всем таблицам листа.""" + result = {} + cells = {} + for code_col, value_cols, head in _find_table(ws): + for r in range(head + 2, ws.max_row + 1): + v = ws.cell(r, code_col).value + if isinstance(v, str) and v.isdigit() and len(v) == 4: + result[v] = [ws.cell(r, c).value for c in value_cols] + cells[v] = [ws.cell(r, c) for c in value_cols] + return result, cells + + +def _sample_signers() -> list[str]: + names = ["Косарев Константин Евгеньевич", "Лушникова Светлана Михайловна"] + if SAMPLE_DIR.exists(): + ws = openpyxl.load_workbook(SAMPLE_DIR / "1_Форма1_бух_09.xlsx").active + names += [ws["Q80"].value, ws["Q83"].value] + return [n for n in names if n] + + +@pytest.mark.parametrize("name,last_col", [("bb_1c.xlsx", 20), ("ofr_1c.xlsx", 18)]) +def test_template_sanitized(name, last_col): + path = TEMPLATES / name + wb = openpyxl.load_workbook(path) + ws = wb.active + for row in ws.iter_rows(): + for c in row: + assert not (isinstance(c.value, str) and c.value.startswith("=")), (c.coordinate, c.value) + if c.column > last_col: + assert c.value is None, c.coordinate + raw = _raw_strings(path) + for bad in FORBIDDEN + _sample_signers(): + assert bad not in raw, bad + lines, _ = _read_lines(ws) + assert all(v is None for vals in lines.values() for v in vals), "в шаблоне нет значений строк" + assert set(forms.BALANCE_CODES if name == "bb_1c.xlsx" else forms.INCOME_CODES) == set(lines) + + +def test_written_file_no_sample_values(sample_forms): + signers = _sample_signers() + for form, path in sample_forms: + raw = _raw_strings(path) + for bad in FORBIDDEN + signers: + assert bad not in raw, (path.name, bad) + ws = openpyxl.load_workbook(path).active + last = 20 if form.kind == "BALANCE" else 18 + assert all(c.value is None for row in ws.iter_rows(min_col=last + 1) for c in row) + assert not any(isinstance(c.value, str) and c.value.startswith("=") for row in ws.iter_rows() for c in row) + + +def test_file_name(): + ps = build_periods("2025-Q1", 4) + + class F: # только то, что нужно file_name + def __init__(self, kind, period): + self.kind, self.period = kind, period + + assert forms_xlsx.file_name(F("BALANCE", ps[2])) == "ББ_9 месяцев 2025 г.xlsx" + assert forms_xlsx.file_name(F("INCOME", ps[3])) == "ОФР_2025 г.xlsx" + + +def test_codes_and_values(sample_forms): + for form, path in sample_forms: + ws = openpyxl.load_workbook(path).active + lines, cells = _read_lines(ws) + codes = forms.BALANCE_CODES if form.kind == "BALANCE" else forms.INCOME_CODES + assert set(lines) == set(codes), path.name + for code in codes: + for col, want in enumerate(form.values[code]): + got = lines[code][col] + assert isinstance(got, int) and got == want, (path.name, code, col, got, want) + assert cells[code][col].number_format == (ZERO_FORMAT if want == 0 else "#,##0"), (code, col) + assert any(v == 0 for f, _ in sample_forms for vals in f.values.values() for v in vals) + + +def test_requisites(sample_forms): + for form, path in sample_forms: + ws = openpyxl.load_workbook(path).active + m, p = form.member, form.period + title = ws["B2"].value + assert title == ("БУХГАЛТЕРСКИЙ БАЛАНС" if form.kind == "BALANCE" else "ОТЧЕТ О ФИНАНСОВЫХ РЕЗУЛЬТАТАХ") + assert ws["B3"].value == f"за {p.label_1c}" + assert ws["G7"].value == m.name + if form.kind == "BALANCE": + assert ws["R8"].value == m.inn and ws["R6"].value == p.end.day and ws["S6"].value == p.end.month + assert ws["T6"].value == p.end.year and ws["R7"].value and ws["R7"].value != "85262693" + head = [ws["M23"].value, ws["Q23"].value, ws["S23"].value] + head2 = [ws["M48"].value, ws["Q48"].value, ws["S48"].value] + else: + assert ws["P8"].value == m.inn and ws["P6"].value == p.end.day and ws["Q6"].value == p.end.month + assert ws["R6"].value == p.end.year + head = [ws["O14"].value, ws["Q14"].value] + head2 = [ws["O36"].value, ws["Q36"].value] + assert head == form.columns and head2 == form.columns + if form.kind == "BALANCE": + assert ws["G13"].value and "Томск" not in ws["G13"].value + assert ws["G10"].value and (ws["G10"].value.startswith("АО") if m.org.form == "АО" else ws["G10"].value.startswith("ООО")) + + +def test_signers_are_synthetic(sample_forms): + form, path = sample_forms[0] + ws = openpyxl.load_workbook(path).active + cell = ws["Q80"] if form.kind == "BALANCE" else ws["O49"] + cell2 = ws["Q83"] if form.kind == "BALANCE" else ws["O52"] + assert cell.value and cell2.value and cell.value not in _sample_signers() and cell.value != cell2.value + assert len(cell.value.split()) == 3 + + +def test_style_kept(sample_forms): + for form, path in sample_forms: + name = "bb_1c.xlsx" if form.kind == "BALANCE" else "ofr_1c.xlsx" + tpl = openpyxl.load_workbook(TEMPLATES / name).active + ws = openpyxl.load_workbook(path).active + assert ws["B2"].font.name == "Times New Roman" and ws["B2"].font.b and ws["B2"].font.sz == 12 + assert {str(r) for r in ws.merged_cells.ranges} == {str(r) for r in tpl.merged_cells.ranges} and ws.merged_cells.ranges + assert {k: v.width for k, v in ws.column_dimensions.items()} == {k: v.width for k, v in tpl.column_dimensions.items()} + assert {k: v.height for k, v in ws.row_dimensions.items()} == {k: v.height for k, v in tpl.row_dimensions.items()} + _, cells = _read_lines(ws) + _, tcells = _read_lines(tpl) + code = "1150" if form.kind == "BALANCE" else "2110" + assert cells[code][0].font.name == "Times New Roman" and cells[code][0].font.sz == tcells[code][0].font.sz + assert cells[code][0].border.left.style and cells[code][0].border.left.style == tcells[code][0].border.left.style + + +def test_deterministic_bytes(sample_forms, tmp_path): + form, path = sample_forms[0] + again = tmp_path / "again.xlsx" + forms_xlsx.write(form, again) + assert again.read_bytes() == path.read_bytes() + + +def test_negative_expenses_written_as_numbers(sample_forms): + for form, path in sample_forms: + if form.kind != "INCOME": + continue + lines, _ = _read_lines(openpyxl.load_workbook(path).active) + assert all(not isinstance(v, str) for vals in lines.values() for v in vals), "числа, не текст со скобками" + assert lines["2120"][0] <= 0 and Decimal(lines["2120"][0]) == form.values["2120"][0] diff --git a/synthgen/tests/test_ledger.py b/synthgen/tests/test_ledger.py index c9a0aa0..e9c5073 100644 --- a/synthgen/tests/test_ledger.py +++ b/synthgen/tests/test_ledger.py @@ -76,3 +76,18 @@ def test_chart_codes_in_core_seed(): codes = {r["code"] for r in csv.DictReader(seed.open(encoding="utf-8"))} missing = [c for c in chart.ACCOUNTS if c != "000" and c not in codes] assert not missing + + +def test_closing_postings_have_explicit_kind(osv_world): + """T3.8: закрывающие проводки различаются полем kind, а не разбором memo.""" + from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99, KIND_CLOSE_COST, KIND_ORDINARY + + _, _, _, books = osv_world + kinds = {"закрытие на 90.09": KIND_CLOSE_90, "закрытие на 99": KIND_CLOSE_99, "закрытие затрат": KIND_CLOSE_COST} + seen = set() + for lg in books.values(): + for p in lg.postings: + want = kinds.get(p.memo, KIND_ORDINARY) + assert p.kind == want, (p.memo, p.kind) + seen.add(p.kind) + assert {KIND_CLOSE_90, KIND_CLOSE_99, KIND_CLOSE_COST, KIND_ORDINARY} <= seen