synthgen: ББ и ОФР по шаблону 1С, явный kind закрывающих проводок (#38)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a4a44a6b3
commit
94ff75fbb1
@ -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 % сжимает объём пар |
|
| 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` |
|
| 33 | 12.11.2026 | Свёртка ОСВ (`osv.rows`) идёт по индексу книги с накопленными по дням итогами (счёт, субсчёт, контрагент), а не через `Ledger.balance/turnover` на каждый период; результат совпадает с ними (тест) | на каждый период и счёт проходить по всем проводкам книги слишком долго | нет: расхождение ловится `test_rows_match_ledger` |
|
||||||
| 34 | 12.11.2026 | Строки ОСВ без сальдо и оборотов не пишутся; у счёта 80 без аналитики (головной) строка счёта без раскрытия; «Итого» — сумма строк счёта; колонка ИНН пишется только у контрагентов с ИНН | как в выгрузке 1С; ДП не требует нулевых строк | нет: парсер принимает, тест `-m parser` |
|
| 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н требуют реквизитов; ничего из образца не должно попасть в набор | ОКПО/ОКОПФ вымышлены, не проверяются классификаторами |
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import math
|
|||||||
from synthgen.config import Config
|
from synthgen.config import Config
|
||||||
from synthgen.counterparties import CounterpartyPlan
|
from synthgen.counterparties import CounterpartyPlan
|
||||||
from synthgen.group import Group, Member
|
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.periods import Period, book_end, book_start
|
||||||
from synthgen.rng import rng_for
|
from synthgen.rng import rng_for
|
||||||
|
|
||||||
@ -368,30 +368,30 @@ def _close_all(lg: Ledger, day: date, prefix: str) -> Decimal:
|
|||||||
result = D(0)
|
result = D(0)
|
||||||
for (acct, an), bal in sorted(lg.balance(day, prefix).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
|
for (acct, an), bal in sorted(lg.balance(day, prefix).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
|
||||||
if bal > 0:
|
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:
|
else:
|
||||||
lg.post(day, acct, an, "99", None, -bal, "закрытие на 99")
|
lg.post(day, acct, an, "99", None, -bal, "закрытие на 99", KIND_CLOSE_99)
|
||||||
result -= bal
|
result -= bal
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _close_90(lg: Ledger, day: date) -> Decimal:
|
def _close_90(lg: Ledger, day: date) -> Decimal:
|
||||||
"""Как в 1С: сальдо 90.01..90.08 переносятся на 90.09 (проводки с memo «закрытие на 90.09»), затем 90.09 — на 99
|
"""Как в 1С: сальдо 90.01..90.08 переносятся на 90.09 (проводки `KIND_CLOSE_90`), затем 90.09 — на 99
|
||||||
(memo «закрытие на 99»). Обороты 90.09 — только закрывающие; свёртка ОФР различает их по memo."""
|
(`KIND_CLOSE_99`). Обороты 90.09 — только закрывающие; свёртка ОФР различает их по `Posting.kind`."""
|
||||||
result = D(0)
|
result = D(0)
|
||||||
for (acct, an), bal in sorted(lg.balance(day, "90").items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
|
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":
|
if acct == "90.09":
|
||||||
continue
|
continue
|
||||||
if bal > 0:
|
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:
|
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
|
result -= bal
|
||||||
bal09 = lg.balance(day, "90.09").get(("90.09", None), D(0))
|
bal09 = lg.balance(day, "90.09").get(("90.09", None), D(0))
|
||||||
if bal09 > 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:
|
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
|
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 cost, target in (("26", "90.08"), ("44", "90.07")):
|
||||||
for (acct, an), bal in lg.balance(day, cost).items():
|
for (acct, an), bal in lg.balance(day, cost).items():
|
||||||
if bal > 0:
|
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")
|
result = _close_90(lg, day) + _close_all(lg, day, "91")
|
||||||
tax = q(result * PROFIT_TAX)
|
tax = q(result * PROFIT_TAX)
|
||||||
if tax > 0:
|
if tax > 0:
|
||||||
|
|||||||
290
synthgen/forms.py
Normal file
290
synthgen/forms.py
Normal 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
109
synthgen/forms_xlsx.py
Normal 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)
|
||||||
@ -8,6 +8,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
CENT = Decimal("0.01")
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class An:
|
class An:
|
||||||
@ -27,6 +33,7 @@ class Posting:
|
|||||||
ct_an: An | None
|
ct_an: An | None
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
memo: str
|
memo: str
|
||||||
|
kind: str = KIND_ORDINARY
|
||||||
|
|
||||||
|
|
||||||
Key = tuple[str, An | None]
|
Key = tuple[str, An | None]
|
||||||
@ -43,10 +50,10 @@ class Ledger:
|
|||||||
self._maxday: date | None = None
|
self._maxday: date | None = None
|
||||||
self.meta: dict = {} # произвольные данные модели компании (используются economy)
|
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):
|
if amount <= 0 or amount != amount.quantize(CENT):
|
||||||
raise ValueError(f"сумма должна быть > 0 с двумя знаками: {amount}")
|
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()
|
self._cache.clear()
|
||||||
kd, kc = (dt, dt_an), (ct, ct_an)
|
kd, kc = (dt, dt_an), (ct, ct_an)
|
||||||
self._tot[kd] = self._tot.get(kd, Decimal(0)) + amount
|
self._tot[kd] = self._tot.get(kd, Decimal(0)) + amount
|
||||||
|
|||||||
BIN
synthgen/templates/bb_1c.xlsx
Normal file
BIN
synthgen/templates/bb_1c.xlsx
Normal file
Binary file not shown.
82
synthgen/templates/build_forms_templates.py
Normal file
82
synthgen/templates/build_forms_templates.py
Normal 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)
|
||||||
BIN
synthgen/templates/ofr_1c.xlsx
Normal file
BIN
synthgen/templates/ofr_1c.xlsx
Normal file
Binary file not shown.
@ -43,3 +43,21 @@ def osv_world():
|
|||||||
periods = build_periods(cfg.start, cfg.periods)
|
periods = build_periods(cfg.start, cfg.periods)
|
||||||
books, _ = build(cfg, group, periods, make_plan(cfg, group))
|
books, _ = build(cfg, group, periods, make_plan(cfg, group))
|
||||||
return cfg, group, periods, books
|
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
|
||||||
|
|||||||
237
synthgen/tests/test_forms.py
Normal file
237
synthgen/tests/test_forms.py
Normal 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 года"
|
||||||
109
synthgen/tests/test_forms_parser.py
Normal file
109
synthgen/tests/test_forms_parser.py
Normal 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]
|
||||||
185
synthgen/tests/test_forms_xlsx.py
Normal file
185
synthgen/tests/test_forms_xlsx.py
Normal 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]
|
||||||
@ -76,3 +76,18 @@ def test_chart_codes_in_core_seed():
|
|||||||
codes = {r["code"] for r in csv.DictReader(seed.open(encoding="utf-8"))}
|
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]
|
missing = [c for c in chart.ACCOUNTS if c != "000" and c not in codes]
|
||||||
assert not missing
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user