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 0000000..7b32019 Binary files /dev/null and b/synthgen/templates/bb_1c.xlsx differ diff --git a/synthgen/templates/build_forms_templates.py b/synthgen/templates/build_forms_templates.py new file mode 100644 index 0000000..dd65d5c --- /dev/null +++ b/synthgen/templates/build_forms_templates.py @@ -0,0 +1,82 @@ +"""Сборка шаблонов ББ и ОФР в оформлении 1С (`bb_1c.xlsx`, `ofr_1c.xlsx`) из реальных образцов — один раз, результат коммитится. + +Запуск из корня worktree: + uv run -q --with openpyxl==3.1.5 python -m synthgen.templates.build_forms_templates [каталог образцов] + +Книга образца копируется целиком (шрифты Times New Roman, объединения, рамки, высоты строк, форматы, блок «КОДЫ», +разрывы страниц, неизменяемые тексты форм 66н). В шаблоне очищено всё, что принадлежит образцу или считается генератором: +- все формулы (итоги, заголовки второй страницы, служебные расчёты) заменены пустыми значениями; +- числа в колонках значений строк с кодами, наименование организации, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, вид деятельности, + адрес, дата формы, периодные подписи колонок и заголовка, ФИО подписантов; +- ячейки правее последней колонки формы (служебные расчёты автора образца) — значения и оформление. +Генератор (`synthgen.forms_xlsx`) пишет свои значения по кодам строк. +""" + +import sys +from pathlib import Path + +import openpyxl +from openpyxl.cell.cell import MergedCell +from openpyxl.styles import Alignment, Border, Font, PatternFill + +from synthgen import xlsx + +SAMPLES = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/statements-xlsx/mezheninovskaya/2025-Q3") +HERE = Path(__file__).parent +# (файл образца, имя шаблона, последняя колонка формы, колонка кодов, ячейки реквизитов и подписей для очистки) +BUILD = { + "BALANCE": ( + "1_Форма1_бух_09.xlsx", "bb_1c.xlsx", 20, 11, + ("B3", "G7", "R6", "S6", "T6", "R7", "R8", "G9", "R9", "G10", "R10", "S10", "G13", "M23", "Q23", "S23", "M48", "Q48", "S48", + "Q80", "Q83"), + ), + "INCOME": ( + "1_Форма2_бух_09.xlsx", "ofr_1c.xlsx", 18, 13, + ("B3", "G7", "P6", "Q6", "R6", "P7", "P8", "G9", "P9", "G10", "P10", "Q10", "O14", "Q14", "O36", "Q36", "O49", "O52"), + ), +} # fmt: skip + + +def _is_code(value) -> 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 0000000..6809e31 Binary files /dev/null and b/synthgen/templates/ofr_1c.xlsx differ diff --git a/synthgen/tests/conftest.py b/synthgen/tests/conftest.py index 23f816b..94e3af9 100644 --- a/synthgen/tests/conftest.py +++ b/synthgen/tests/conftest.py @@ -43,3 +43,21 @@ def osv_world(): periods = build_periods(cfg.start, cfg.periods) books, _ = build(cfg, group, periods, make_plan(cfg, group)) return cfg, group, periods, books + + +@pytest.fixture(scope="session") +def forms_world(): + """Группа с книгами за 5 периодов подряд (2025-Q1 … 2026-Q1) для тестов ББ/ОФР: сравнительные колонки + последнего периода целиком лежат в книге: (cfg, group, periods, books).""" + from synthgen import dictionary + from synthgen.config import Config + from synthgen.counterparties import plan as make_plan + from synthgen.group import build_group + from synthgen.intragroup import build + from synthgen.periods import build_periods + + cfg = Config(group_size=3, periods=5, osv_counterparties_mean=6, no_inn_companies=1) + group = build_group(cfg, dictionary.load()) + periods = build_periods(cfg.start, cfg.periods) + books, _ = build(cfg, group, periods, make_plan(cfg, group)) + return cfg, group, periods, books diff --git a/synthgen/tests/test_forms.py b/synthgen/tests/test_forms.py new file mode 100644 index 0000000..0457011 --- /dev/null +++ b/synthgen/tests/test_forms.py @@ -0,0 +1,237 @@ +from collections import defaultdict +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +import pytest + +from synthgen import forms +from synthgen.ledger import KIND_CLOSE_90, KIND_CLOSE_99 +from synthgen.periods import book_start + +ZERO = Decimal(0) +SEED = 1 +SECTIONS = { + "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"), +} + + +def _thousands(x: Decimal) -> 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