synthgen: ББ и ОФР по шаблону 1С, явный kind закрывающих проводок (#38)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-13 00:30:31 +04:00
parent 7a4a44a6b3
commit 94ff75fbb1
13 changed files with 1069 additions and 12 deletions

View File

@ -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н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами |

View File

@ -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:

290
synthgen/forms.py Normal file
View File

@ -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)

109
synthgen/forms_xlsx.py Normal file
View File

@ -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)

View File

@ -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

Binary file not shown.

View File

@ -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)

Binary file not shown.

View File

@ -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

View File

@ -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 года"

View File

@ -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]

View File

@ -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]

View File

@ -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