synthgen: ОСВ в оформлении 1С (#38)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8f1b4a7312
commit
6fb42be8ff
@ -294,3 +294,5 @@ README прогона, точные субсчета внутри перечис
|
||||
| 30 | 12.11.2026 | Дивиденды: май каждого года книги, 3 % годовой выручки дочерней; доля головной по `head_share` (Дт 84 Кт 76.03 `An(cp=<код>)` ↔ Дт 76.03 Кт 91.01 item «Дивиденды»), остаток — миноритарию через `An(cp=<ИНН>)`; выплата в день 20 того же месяца | решение 11 требует нулевого сальдо 76.03; миноритарий получает свою долю | если нужны дивиденды из прибыли года — привязать к 84 |
|
||||
| 31 | 12.11.2026 | Проценты по займам группы — 10 % годовых, начисляются и уплачиваются 28-го числа каждого месяца по субсчёту 76.09 обеих сторон | пары 76↔76 по правилу 6 ДП §4, остаток 76.09 нулевой | нет |
|
||||
| 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` |
|
||||
|
||||
341
synthgen/osv.py
Normal file
341
synthgen/osv.py
Normal file
@ -0,0 +1,341 @@
|
||||
"""ОСВ по счёту в оформлении 1С 8.3 (спека §6.1): свёртка книги в строки и запись xlsx.
|
||||
|
||||
`rows` свёртывает книгу компании в строки «счёт → субсчёт → контрагент» за период (нарастающим итогом с 1 января):
|
||||
сальдо на начало/конец — по каждой строке одной стороной (Дт или Кт), обороты — обе; строка родителя равна сумме детей
|
||||
по каждой из шести колонок. Строки без сальдо и оборотов не пишутся, как в 1С. `write` рисует лист по шаблону
|
||||
`templates/osv_style.json` (собран из образца скриптом `build_osv_style.py`); у ОСВ с ИНН между блоком текста и блоком
|
||||
сумм вставляется колонка ИНН, остальная сетка сдвигается на одну колонку (решение 3).
|
||||
|
||||
Производительность (решение 33): для свёртки по периодам книга один раз индексируется по (счёт, контрагент) с
|
||||
накопленными итогами по дням; `Ledger.balance/turnover` на каждый период проходили бы по всем проводкам.
|
||||
"""
|
||||
|
||||
import json
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.properties import Outline
|
||||
|
||||
from synthgen import xlsx
|
||||
from synthgen.counterparties import OSV_ACCOUNTS
|
||||
from synthgen.group import Group, Member, variant_spelling
|
||||
from synthgen.ledger import Ledger
|
||||
from synthgen.periods import Period
|
||||
|
||||
ZERO = Decimal(0)
|
||||
STYLE_PATH = Path(__file__).with_name("templates") / "osv_style.json"
|
||||
SHEET = "Лист_1"
|
||||
Six = tuple[Decimal, Decimal, Decimal, Decimal, Decimal, Decimal]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OsvRow:
|
||||
level: int # 0 счёт, 1 субсчёт, 2 контрагент (глубже — вложенные субсчета)
|
||||
label: str # код счёта/субсчёта или имя контрагента
|
||||
inn: str | None
|
||||
od: Decimal # сальдо на начало, Дт
|
||||
oc: Decimal # сальдо на начало, Кт
|
||||
td: Decimal # обороты, Дт
|
||||
tc: Decimal # обороты, Кт
|
||||
cd: Decimal # сальдо на конец, Дт
|
||||
cc: Decimal # сальдо на конец, Кт
|
||||
is_cp: bool = False # строка контрагента
|
||||
|
||||
@property
|
||||
def six(self) -> Six:
|
||||
return (self.od, self.oc, self.td, self.tc, self.cd, self.cc)
|
||||
|
||||
|
||||
def file_name(account: str, period: Period) -> str:
|
||||
# метка периода заканчивается точкой после «г», она же — точка перед расширением
|
||||
return f"сч {account}_{period.label_1c[:-1]}.xlsx"
|
||||
|
||||
|
||||
# --- индекс книги -----------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Series:
|
||||
"""Накопленные по дням итоги одного ключа (счёт, контрагент): сальдо (Дт − Кт), обороты Дт и Кт."""
|
||||
|
||||
__slots__ = ("days", "net", "dt", "ct")
|
||||
|
||||
def __init__(self, items: list[tuple[int, Decimal, Decimal]]):
|
||||
items.sort(key=lambda x: x[0])
|
||||
self.days = [d for d, _, _ in items]
|
||||
self.net, self.dt, self.ct = [], [], []
|
||||
n = a = b = ZERO
|
||||
for _, debit, credit in items:
|
||||
a += debit
|
||||
b += credit
|
||||
n = a - b
|
||||
self.net.append(n)
|
||||
self.dt.append(a)
|
||||
self.ct.append(b)
|
||||
|
||||
def _at(self, arr: list[Decimal], ordinal: int) -> Decimal:
|
||||
i = bisect_right(self.days, ordinal)
|
||||
return arr[i - 1] if i else ZERO
|
||||
|
||||
def amounts(self, start_ord: int, end_ord: int) -> tuple[Decimal, Decimal, Decimal, Decimal]:
|
||||
"""(сальдо на начало, обороты Дт, обороты Кт, сальдо на конец) за [start, end]."""
|
||||
before = start_ord - 1
|
||||
return (
|
||||
self._at(self.net, before),
|
||||
self._at(self.dt, end_ord) - self._at(self.dt, before),
|
||||
self._at(self.ct, end_ord) - self._at(self.ct, before),
|
||||
self._at(self.net, end_ord),
|
||||
)
|
||||
|
||||
|
||||
def _index(ledger: Ledger) -> dict[str, dict[tuple[str, str | None], _Series]]:
|
||||
"""Корень счёта → {(код счёта, контрагент или None): серия}; пересобирается при изменении числа проводок."""
|
||||
cached = ledger.meta.get("_osv_index")
|
||||
if cached is not None and cached[0] == len(ledger.postings):
|
||||
return cached[1]
|
||||
raw: dict[str, dict[tuple[str, str | None], list[tuple[int, Decimal, Decimal]]]] = {}
|
||||
for p in ledger.postings:
|
||||
ordinal = p.day.toordinal()
|
||||
for code, an, debit, credit in ((p.dt, p.dt_an, p.amount, ZERO), (p.ct, p.ct_an, ZERO, p.amount)):
|
||||
root = code.split(".")[0]
|
||||
if root in OSV_ACCOUNTS:
|
||||
raw.setdefault(root, {}).setdefault((code, an.cp if an is not None else None), []).append(
|
||||
(ordinal, debit, credit)
|
||||
)
|
||||
index = {root: {key: _Series(items) for key, items in by_key.items()} for root, by_key in raw.items()}
|
||||
ledger.meta["_osv_index"] = (len(ledger.postings), index)
|
||||
return index
|
||||
|
||||
|
||||
# --- имена контрагентов -----------------------------------------------------------------------------------------
|
||||
|
||||
_DIRECTORY: dict[int, tuple[Group, dict[str, tuple[str, str | None]]]] = {}
|
||||
|
||||
|
||||
def _directory(group: Group) -> dict[str, tuple[str, str | None]]:
|
||||
"""Код аналитики контрагента → (имя в 1С, ИНН): внешние и банки по ИНН, члены группы по коду, миноритарии."""
|
||||
cached = _DIRECTORY.get(id(group))
|
||||
if cached is not None and cached[0] is group:
|
||||
return cached[1]
|
||||
out: dict[str, tuple[str, str | None]] = {}
|
||||
for org in [*group.externals, *group.banks, *(m.minority for m in group.members if m.minority)]:
|
||||
out[org.inn] = (org.name, org.inn)
|
||||
for m in group.members:
|
||||
out[m.code] = (m.name_1c, m.inn)
|
||||
_DIRECTORY[id(group)] = (group, out)
|
||||
return out
|
||||
|
||||
|
||||
def _identity(cp: str, member: Member, group: Group) -> tuple[str, str | None]:
|
||||
entry = _directory(group).get(cp)
|
||||
if entry is None:
|
||||
raise ValueError(f"{member.code}: контрагент {cp!r} неизвестен группе (в ОСВ не выгружается)")
|
||||
name, inn = entry
|
||||
if member.code in group.no_inn:
|
||||
variant_code = group.variant_name.get(member.code)
|
||||
if variant_code == cp:
|
||||
name = variant_spelling(next(m for m in group.members if m.code == cp))
|
||||
return name, None
|
||||
return name, inn
|
||||
|
||||
|
||||
# --- свёртка ----------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _natural_key(code: str) -> list[tuple[int, int, str]]:
|
||||
return [(0, int(part), "") if part.isdigit() else (1, 0, part) for part in code.split(".")]
|
||||
|
||||
|
||||
def _sided(net: Decimal) -> tuple[Decimal, Decimal]:
|
||||
return (net, ZERO) if net > 0 else (ZERO, -net)
|
||||
|
||||
|
||||
def _add(a: Six, b: Six) -> Six:
|
||||
return tuple(x + y for x, y in zip(a, b, strict=True)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def rows(ledger: Ledger, account: str, period: Period, member: Member, group: Group) -> list[OsvRow]:
|
||||
"""Строки ОСВ по счёту в порядке вывода (счёт, субсчета по возрастанию кода, контрагенты по имени); без «Итого»."""
|
||||
start, end = period.start.toordinal(), period.end.toordinal()
|
||||
leaves: dict[str, dict[str | None, Six]] = {}
|
||||
for (code, cp), series in _index(ledger).get(account, {}).items():
|
||||
opening, td, tc, closing = series.amounts(start, end)
|
||||
od, oc = _sided(opening)
|
||||
cd, cc = _sided(closing)
|
||||
six: Six = (od, oc, td, tc, cd, cc)
|
||||
if any(six):
|
||||
leaves.setdefault(code, {})[cp] = six
|
||||
|
||||
children: dict[str, set[str]] = {}
|
||||
for code in leaves:
|
||||
parts = code.split(".")
|
||||
for i in range(len(parts)):
|
||||
prefix = ".".join(parts[: i + 1])
|
||||
children.setdefault(prefix, set())
|
||||
if i:
|
||||
children[".".join(parts[:i])].add(prefix)
|
||||
for code, subs in children.items():
|
||||
if subs and code in leaves:
|
||||
raise ValueError(f"{member.code} {account}: счёт {code} и с проводками, и с субсчетами")
|
||||
|
||||
def leaf_rows(code: str, level: int) -> tuple[list[OsvRow], Six]:
|
||||
by_cp = leaves[code]
|
||||
if None in by_cp:
|
||||
if len(by_cp) > 1:
|
||||
raise ValueError(f"{member.code} {code}: строки без контрагента и с контрагентом вместе")
|
||||
return [], by_cp[None]
|
||||
named = sorted(((*_identity(cp, member, group), cp) for cp in by_cp if cp is not None), key=lambda x: (x[0], x[2]))
|
||||
out: list[OsvRow] = []
|
||||
total: Six = (ZERO,) * 6
|
||||
for name, inn, cp in named:
|
||||
six = by_cp[cp]
|
||||
total = _add(total, six)
|
||||
out.append(OsvRow(level, name, inn, *six, is_cp=True))
|
||||
return out, total
|
||||
|
||||
def build(code: str, level: int) -> tuple[list[OsvRow], Six]:
|
||||
if code in leaves:
|
||||
body, total = leaf_rows(code, level + 1)
|
||||
else:
|
||||
body, total = [], (ZERO,) * 6
|
||||
for sub in sorted(children[code], key=_natural_key):
|
||||
sub_rows, sub_total = build(sub, level + 1)
|
||||
body += sub_rows
|
||||
total = _add(total, sub_total)
|
||||
return [OsvRow(level, code, None, *total), *body], total
|
||||
|
||||
if account not in children:
|
||||
return []
|
||||
return build(account, 0)[0]
|
||||
|
||||
|
||||
def total_of(data: list[OsvRow]) -> Six:
|
||||
"""«Итого» листа: сумма строк счетов уровня 0 (в листе она одна; у пустой ОСВ — нули)."""
|
||||
result: Six = (ZERO,) * 6
|
||||
for r in data:
|
||||
if r.level == 0:
|
||||
result = _add(result, r.six)
|
||||
return result
|
||||
|
||||
|
||||
# --- лист -------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cache
|
||||
def _style() -> dict:
|
||||
return json.loads(STYLE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _side(spec: dict | None) -> Side:
|
||||
if not spec:
|
||||
return Side()
|
||||
return Side(style=spec["style"], color=spec["color"]) if spec["color"] else Side(style=spec["style"])
|
||||
|
||||
|
||||
class _Styles:
|
||||
"""Готовые объекты openpyxl по шаблону: (тип строки, колонка шаблона) → (шрифт, заливка, рамка, выравнивание, формат)."""
|
||||
|
||||
def __init__(self, tpl: dict):
|
||||
self._tpl = tpl
|
||||
self._cache: dict[tuple[str, int, int | None], tuple] = {}
|
||||
|
||||
def get(self, kind: str, col: int, indent: int | None = None) -> tuple:
|
||||
key = (kind, col, indent)
|
||||
hit = self._cache.get(key)
|
||||
if hit is None:
|
||||
spec = self._spec(kind)["cells"][col - 1]
|
||||
f, a = spec["font"], spec["align"]
|
||||
font = Font(name=f["name"], sz=f["sz"], b=f["b"], i=f["i"], color=f["color"])
|
||||
fill = PatternFill("solid", fgColor=spec["fill"]) if spec["fill"] else PatternFill()
|
||||
bd = spec["border"]
|
||||
border = Border(left=_side(bd["left"]), right=_side(bd["right"]), top=_side(bd["top"]), bottom=_side(bd["bottom"]))
|
||||
align = Alignment(horizontal=a["h"], vertical=a["v"], wrap_text=a["wrap"], indent=a["indent"] if indent is None else indent)
|
||||
hit = self._cache[key] = (font, fill, border, align, spec["nf"])
|
||||
return hit
|
||||
|
||||
def _spec(self, kind: str) -> dict:
|
||||
if kind.startswith("header"):
|
||||
return self._tpl["header"][int(kind[6:]) - 1]
|
||||
return self._tpl["total"] if kind == "total" else self._tpl["rows"][kind]
|
||||
|
||||
|
||||
def _apply(cell, style: tuple) -> None:
|
||||
cell.font, cell.fill, cell.border, cell.alignment, cell.number_format = style
|
||||
|
||||
|
||||
def write(path: Path, member: Member, account: str, period: Period, data: list[OsvRow], with_inn: bool) -> None:
|
||||
"""Пишет лист ОСВ по шаблону 1С; `with_inn` вставляет колонку ИНН после блока текста (E)."""
|
||||
tpl = _style()
|
||||
st = _Styles(tpl)
|
||||
text_end = tpl["text_span"][1] # колонка D
|
||||
inn_col = text_end + 1
|
||||
shift = 1 if with_inn else 0
|
||||
|
||||
def col(c: int) -> int: # колонка шаблона → колонка листа
|
||||
return c + shift if c > text_end else c
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = SHEET
|
||||
ws.sheet_properties.outlinePr = Outline(summaryBelow=bool(tpl["summary_below"]), summaryRight=True)
|
||||
ncols = tpl["ncols"] + shift
|
||||
for c, width in tpl["col_widths"].items():
|
||||
ws.column_dimensions[get_column_letter(col(int(c)))].width = width
|
||||
if with_inn:
|
||||
ws.column_dimensions[get_column_letter(inn_col)].width = tpl["inn_width"]
|
||||
|
||||
def style_row(r: int, kind: str, indent: int | None = None) -> None:
|
||||
for c in range(1, ncols + 1):
|
||||
tc = c if c <= text_end else (text_end if c == inn_col and with_inn else c - shift)
|
||||
_apply(ws.cell(r, c), st.get(kind, tc, indent if tc == 1 else None))
|
||||
|
||||
placeholders = {"{org}": member.name, "{title}": f"Оборотно-сальдовая ведомость по счету {account} за {period.label_1c}"}
|
||||
for i, row in enumerate(tpl["header"], start=1):
|
||||
style_row(i, f"header{i}")
|
||||
ws.row_dimensions[i].height = row["height"]
|
||||
ws.row_dimensions[i].outlineLevel = row["outline"]
|
||||
for c, value in row["values"].items():
|
||||
ws.cell(i, col(int(c)), placeholders.get(value, value))
|
||||
if with_inn:
|
||||
ws.cell(10, inn_col, "ИНН")
|
||||
for r1, c1, r2, c2 in tpl["header_merges"]:
|
||||
ws.merge_cells(start_row=r1, start_column=col(c1), end_row=r2, end_column=col(c2))
|
||||
if with_inn:
|
||||
ws.merge_cells(start_row=10, start_column=inn_col, end_row=11, end_column=inn_col)
|
||||
|
||||
spans = [(col(a), col(b)) for a, b in tpl["sum_spans"]]
|
||||
|
||||
def put_sums(r: int, six: Six) -> None:
|
||||
for (a, b), value in zip(spans, six, strict=True):
|
||||
if value != 0:
|
||||
ws.cell(r, a, float(value))
|
||||
if b > a:
|
||||
ws.merge_cells(start_row=r, start_column=a, end_row=r, end_column=b)
|
||||
|
||||
def put_label(r: int, text: str) -> None:
|
||||
ws.cell(r, 1, text)
|
||||
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=text_end)
|
||||
|
||||
r = len(tpl["header"]) + 1
|
||||
for item in data:
|
||||
kind = "cp" if item.is_cp else ("account" if item.level == 0 else "sub")
|
||||
style_row(r, kind, item.level)
|
||||
spec = tpl["rows"][kind]
|
||||
ws.row_dimensions[r].height = spec["height"]
|
||||
ws.row_dimensions[r].outlineLevel = min(item.level, 7)
|
||||
put_label(r, item.label)
|
||||
if with_inn and item.inn:
|
||||
ws.cell(r, inn_col, item.inn)
|
||||
put_sums(r, item.six)
|
||||
r += 1
|
||||
style_row(r, "total")
|
||||
ws.row_dimensions[r].height = tpl["total"]["height"]
|
||||
put_label(r, "Итого")
|
||||
put_sums(r, total_of(data))
|
||||
xlsx.save_deterministic(wb, path)
|
||||
0
synthgen/templates/__init__.py
Normal file
0
synthgen/templates/__init__.py
Normal file
90
synthgen/templates/build_osv_style.py
Normal file
90
synthgen/templates/build_osv_style.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Сборка шаблона оформления ОСВ 1С (`osv_style.json`) из реального образца — один раз, результат коммитится.
|
||||
|
||||
Запуск из корня worktree:
|
||||
uv run -q --with openpyxl==3.1.5 python -m synthgen.templates.build_osv_style [путь к образцу]
|
||||
|
||||
В JSON попадают только оформление и неизменяемые тексты шапки (строки 1–11): ширины колонок, высоты и уровни
|
||||
группировки строк, шрифты, заливки, рамки, выравнивание, объединения. Название организации и период в шапке
|
||||
заменены плейсхолдерами `{org}` и `{title}`; ни одно значение данных образца (контрагенты, суммы) в шаблон не попадает.
|
||||
Сетка — «блок текста A:D + блок сумм E:Q»; колонка ИНН вставляется между ними при записи (`synthgen.osv`).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
SAMPLE = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/osv/rb/2024-Q1/сч 60_ 1 кв 2024 г.xlsx")
|
||||
OUT = Path(__file__).with_name("osv_style.json")
|
||||
NCOLS = 17 # A…Q: дальше в образце только служебные хвосты
|
||||
HEADER_ROWS = 11
|
||||
ROW_TYPES = {"account": 12, "sub": 13, "cp": 14} # образцы строк данных; «Итого» ищется по тексту
|
||||
PLACEHOLDERS = {(1, 1): "{org}", (2, 1): "{title}"}
|
||||
|
||||
|
||||
def _side(side) -> dict | None:
|
||||
if not side or not side.style:
|
||||
return None
|
||||
return {"style": side.style, "color": side.color.rgb if side.color is not None and isinstance(side.color.rgb, str) else None}
|
||||
|
||||
|
||||
def _style(cell) -> dict:
|
||||
font, fill, al, bd = cell.font, cell.fill, cell.alignment, cell.border
|
||||
color = font.color.rgb if font.color is not None and isinstance(font.color.rgb, str) else None
|
||||
return {
|
||||
"font": {"name": font.name, "sz": font.sz, "b": bool(font.b), "i": bool(font.i), "color": color},
|
||||
"fill": fill.fgColor.rgb if fill.fill_type == "solid" else None,
|
||||
"border": {k: _side(getattr(bd, k)) for k in ("left", "right", "top", "bottom")},
|
||||
"align": {"h": al.horizontal, "v": al.vertical, "wrap": bool(al.wrap_text), "indent": al.indent or 0},
|
||||
"nf": cell.number_format,
|
||||
}
|
||||
|
||||
|
||||
def _row(ws, r: int) -> dict:
|
||||
dim = ws.row_dimensions[r]
|
||||
return {
|
||||
"height": dim.height,
|
||||
"outline": dim.outlineLevel or 0,
|
||||
"cells": [_style(ws.cell(r, c)) for c in range(1, NCOLS + 1)],
|
||||
}
|
||||
|
||||
|
||||
def build(sample: Path = SAMPLE, out: Path = OUT) -> dict:
|
||||
ws = openpyxl.load_workbook(sample).active
|
||||
total_row = next(r for r in range(ws.max_row, 0, -1) if ws.cell(r, 1).value == "Итого")
|
||||
spans = sorted(
|
||||
(m.min_col, m.max_col) for m in ws.merged_cells.ranges if m.min_row == 10 and m.max_row == 11 and m.min_col > 4
|
||||
)
|
||||
header = []
|
||||
for r in range(1, HEADER_ROWS + 1):
|
||||
row = _row(ws, r)
|
||||
row["values"] = {
|
||||
str(c): PLACEHOLDERS.get((r, c), ws.cell(r, c).value)
|
||||
for c in range(1, NCOLS + 1)
|
||||
if ws.cell(r, c).value is not None
|
||||
}
|
||||
header.append(row)
|
||||
style = {
|
||||
"source": "образец 1С 8.3 (БП 3.0): ОСВ по счёту, лист Лист_1",
|
||||
"ncols": NCOLS,
|
||||
"text_span": [1, 4], # блок «Счет / Контрагенты»: A:D
|
||||
"sum_spans": [list(s) for s in spans], # шесть блоков сумм: сальдо нач. Дт/Кт, обороты Дт/Кт, сальдо кон. Дт/Кт
|
||||
"col_widths": {
|
||||
str(c): ws.column_dimensions[openpyxl.utils.get_column_letter(c)].width for c in range(1, NCOLS + 1)
|
||||
},
|
||||
"inn_width": 11.0,
|
||||
"header": header,
|
||||
"header_merges": sorted([m.min_row, m.min_col, m.max_row, m.max_col] for m in ws.merged_cells.ranges if m.min_row <= HEADER_ROWS),
|
||||
"rows": {name: _row(ws, r) for name, r in ROW_TYPES.items()},
|
||||
"total": _row(ws, total_row),
|
||||
"default_row_height": ws.sheet_format.defaultRowHeight,
|
||||
"summary_below": bool(ws.sheet_properties.outlinePr.summaryBelow),
|
||||
"grid_lines": ws.sheet_view.showGridLines is not False,
|
||||
}
|
||||
out.write_text(json.dumps(style, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return style
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build(Path(sys.argv[1]) if len(sys.argv) > 1 else SAMPLE)
|
||||
7107
synthgen/templates/osv_style.json
Normal file
7107
synthgen/templates/osv_style.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -26,3 +26,20 @@ def pytest_collection_modifyitems(config, items):
|
||||
item.add_marker(pytest.mark.skip(reason="долгий прогон: запуск с -m slow"))
|
||||
if "parser" in item.keywords and not parser_ready:
|
||||
item.add_marker(pytest.mark.skip(reason="нужен TP_WT (зеркало tabular-parser)"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def osv_world():
|
||||
"""Небольшая группа с книгами за 3 периода (для тестов ОСВ): (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=4, periods=3, osv_counterparties_mean=8, 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
|
||||
|
||||
172
synthgen/tests/test_osv.py
Normal file
172
synthgen/tests/test_osv.py
Normal file
@ -0,0 +1,172 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
from synthgen import osv
|
||||
from synthgen.periods import build_periods
|
||||
|
||||
ZERO = Decimal(0)
|
||||
SAMPLE_MERGES = {
|
||||
"A9:D9", "A10:D11", "E9:I9", "E10:G11", "H10:I11", "J9:N9", "J10:K11", "L10:N11", "O9:Q9", "O10:P11", "Q10:Q11",
|
||||
}
|
||||
|
||||
|
||||
def _pick(osv_world, account="62", inn=True):
|
||||
cfg, group, periods, books = osv_world
|
||||
member = next(m for m in group.members if (m.code not in group.no_inn) == inn)
|
||||
period = periods[-1]
|
||||
data = osv.rows(books[member.code], account, period, member, group)
|
||||
return member, period, data, group, books
|
||||
|
||||
|
||||
def _write(tmp_path, osv_world, account="62", inn=True):
|
||||
member, period, data, group, books = _pick(osv_world, account, inn)
|
||||
path = tmp_path / osv.file_name(account, period)
|
||||
osv.write(path, member, account, period, data, with_inn=inn)
|
||||
return openpyxl.load_workbook(path).active, member, period, data
|
||||
|
||||
|
||||
def test_file_name():
|
||||
ps = build_periods("2025-Q1", 4)
|
||||
assert osv.file_name("60", ps[2]) == "сч 60_9 месяцев 2025 г.xlsx"
|
||||
assert osv.file_name("62", ps[3]) == "сч 62_2025 г.xlsx"
|
||||
|
||||
|
||||
def test_header_texts(tmp_path, osv_world):
|
||||
ws, member, period, _ = _write(tmp_path, osv_world, "60")
|
||||
assert ws["A1"].value == member.name
|
||||
assert ws["A2"].value == f"Оборотно-сальдовая ведомость по счету 60 за {period.label_1c}"
|
||||
text9 = " ".join(str(c.value) for c in ws[9] if c.value)
|
||||
text10 = " ".join(str(c.value) for c in ws[10] if c.value)
|
||||
assert "Счет" in text9 and "Сальдо на начало периода" in text9
|
||||
assert "Обороты за период" in text9 and "Сальдо на конец периода" in text9
|
||||
assert "Контрагенты" in text10 and "Дебет" in text10 + " ".join(str(c.value) for c in ws[11] if c.value)
|
||||
assert "Кредит" in text10 + " ".join(str(c.value) for c in ws[11] if c.value)
|
||||
|
||||
|
||||
def test_style_matches_sample(tmp_path, osv_world):
|
||||
ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False)
|
||||
assert ws["A12"].font.name == "Arial" and ws["A12"].font.sz == 8
|
||||
assert ws["A2"].font.name == "Arial" and ws["A2"].font.sz == 12 and ws["A2"].font.b
|
||||
assert ws["A9"].fill.fgColor.rgb == "FFF4ECC5"
|
||||
assert ws["A12"].fill.fgColor.rgb == "FFF8F2D8"
|
||||
cp_row = next(i for i, r in enumerate(data, start=12) if r.is_cp)
|
||||
sub_row = next(i for i, r in enumerate(data, start=12) if r.level == 1)
|
||||
assert ws.cell(cp_row, 1).fill.fill_type is None
|
||||
assert ws["A9"].border.left.style and ws.cell(cp_row, 5).border.bottom.style
|
||||
assert ws.sheet_properties.outlinePr.summaryBelow is False
|
||||
assert ws.row_dimensions[sub_row].outlineLevel == 1 and ws.row_dimensions[cp_row].outlineLevel == 2
|
||||
assert ws.cell(sub_row, 1).alignment.indent == 1 and ws.cell(cp_row, 1).alignment.indent == 2
|
||||
number = next(c for c in ws[cp_row] if isinstance(c.value, float))
|
||||
assert number.number_format == "#,##0.00"
|
||||
|
||||
|
||||
def test_merges_follow_sample(tmp_path, osv_world):
|
||||
ws, *_ = _write(tmp_path, osv_world, "62", inn=False)
|
||||
got = {str(m) for m in ws.merged_cells.ranges if m.min_row in (9, 10, 11)}
|
||||
assert SAMPLE_MERGES <= got
|
||||
(tmp_path / "x").mkdir()
|
||||
ws2, *_ = _write(tmp_path / "x", osv_world, "62", inn=True)
|
||||
got2 = {str(m) for m in ws2.merged_cells.ranges if m.min_row in (9, 10, 11)}
|
||||
assert {"A9:D9", "A10:D11", "E10:E11", "F9:J9", "F10:H11", "I10:J11", "K10:L11", "M10:O11", "P10:Q11", "R10:R11"} <= got2
|
||||
assert ws2["E10"].value == "ИНН"
|
||||
|
||||
|
||||
def test_zero_is_empty(tmp_path, osv_world):
|
||||
ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False)
|
||||
for i, row in enumerate(data, start=12):
|
||||
spans = [(5, 7), (8, 9), (10, 11), (12, 14), (15, 16), (17, 17)]
|
||||
for (a, _), value in zip(spans, row.six, strict=True):
|
||||
cell = ws.cell(i, a).value
|
||||
assert (cell is None) if value == 0 else (cell == float(value))
|
||||
|
||||
|
||||
def test_total_row(tmp_path, osv_world):
|
||||
ws, _, _, data = _write(tmp_path, osv_world, "62", inn=False)
|
||||
last = 11 + len(data) + 1
|
||||
assert ws.cell(last, 1).value == "Итого"
|
||||
totals = osv.total_of(data)
|
||||
account_row = next(r for r in data if r.level == 0)
|
||||
assert totals == account_row.six
|
||||
for (a, _), value in zip([(5, 7), (8, 9), (10, 11), (12, 14), (15, 16), (17, 17)], totals, strict=True):
|
||||
assert (ws.cell(last, a).value or 0) == pytest.approx(float(value))
|
||||
|
||||
|
||||
def test_no_inn_file(tmp_path, osv_world):
|
||||
ws, member, _, data = _write(tmp_path, osv_world, "62", inn=False)
|
||||
assert ws.max_column <= 17
|
||||
assert all(not (isinstance(c.value, str) and c.value.isdigit() and len(c.value) == 10) for c in ws["A"])
|
||||
assert all(r.inn is None for r in data)
|
||||
assert all(ws.cell(i, 5).value is None or isinstance(ws.cell(i, 5).value, float) for i in range(12, 12 + len(data)))
|
||||
|
||||
|
||||
def test_rows_match_ledger(osv_world):
|
||||
member, period, data, group, books = _pick(osv_world, "62", inn=True)
|
||||
ledger = books[member.code]
|
||||
bal0 = ledger.balance(period.start - timedelta(days=1), "62")
|
||||
bal1 = ledger.balance(period.end, "62")
|
||||
turn = ledger.turnover(period.start, period.end, "62")
|
||||
cps = [r for r in data if r.is_cp]
|
||||
assert cps
|
||||
for key, (td, tc) in turn.items():
|
||||
assert key[1] is not None
|
||||
assert sum(r.td for r in cps) == sum(t[0] for t in turn.values())
|
||||
assert sum(r.tc for r in cps) == sum(t[1] for t in turn.values())
|
||||
assert sum(r.cd - r.cc for r in cps) == sum(bal1.values())
|
||||
assert sum(r.od - r.oc for r in cps) == sum(bal0.values())
|
||||
|
||||
|
||||
def test_parent_sums(osv_world):
|
||||
_, _, data, _, _ = _pick(osv_world, "76")
|
||||
assert data[0].level == 0
|
||||
subs = [r for r in data if r.level == 1]
|
||||
for col in range(6):
|
||||
assert data[0].six[col] == sum(r.six[col] for r in subs)
|
||||
# у субсчёта контрагенты суммируются в его строку
|
||||
for i, r in enumerate(data):
|
||||
if r.level == 1:
|
||||
kids = []
|
||||
for k in data[i + 1:]:
|
||||
if k.level <= 1:
|
||||
break
|
||||
if k.is_cp:
|
||||
kids.append(k)
|
||||
if kids:
|
||||
assert r.six == tuple(sum(k.six[c] for k in kids) for c in range(6))
|
||||
|
||||
|
||||
def test_group_names(osv_world):
|
||||
cfg, group, periods, books = osv_world
|
||||
for member in group.members:
|
||||
data = osv.rows(books[member.code], "62", periods[-1], member, group)
|
||||
names = [r.label for r in data if r.is_cp]
|
||||
others = [m for m in group.members if m.code != member.code]
|
||||
present = [m for m in others if m.name_1c in names or osv.variant_spelling(m) in names]
|
||||
if member.code in group.no_inn:
|
||||
variant = next(m for m in group.members if m.code == group.variant_name[member.code])
|
||||
assert not any(m.name_1c in names for m in others if m is variant)
|
||||
assert all(r.inn is None for r in data)
|
||||
else:
|
||||
assert all(m.name_1c in names for m in present)
|
||||
assert all(r.inn for r in data if r.is_cp)
|
||||
|
||||
|
||||
def test_osv_without_externals(tmp_path):
|
||||
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
|
||||
|
||||
cfg = Config(group_size=3, periods=2, osv_counterparties_mean=0)
|
||||
group = build_group(cfg, dictionary.load())
|
||||
ps = build_periods(cfg.start, cfg.periods)
|
||||
books, _ = build(cfg, group, ps, make_plan(cfg, group))
|
||||
member = group.members[0]
|
||||
data = osv.rows(books[member.code], "60", ps[-1], member, group)
|
||||
path = tmp_path / "x.xlsx"
|
||||
osv.write(path, member, "60", ps[-1], data, with_inn=member.code not in group.no_inn)
|
||||
ws = openpyxl.load_workbook(path).active
|
||||
assert any(c.value == "Итого" for c in ws["A"])
|
||||
87
synthgen/tests/test_osv_parser.py
Normal file
87
synthgen/tests/test_osv_parser.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Приёмка ОСВ парсером: CLI tabular-parser из зеркального worktree (`TP_WT`), маркер `parser`.
|
||||
|
||||
Запуск: `$UVRUN python -m pytest synthgen/tests -q -m parser`. Без окружения тесты пропускаются.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from synthgen import osv
|
||||
|
||||
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"
|
||||
ACCOUNTS = ("60", "62", "76")
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parsed(osv_world, tmp_path_factory):
|
||||
if not PY.exists():
|
||||
pytest.skip(f"нет окружения парсера: {PY}")
|
||||
cfg, group, periods, books = osv_world
|
||||
root = tmp_path_factory.mktemp("osv")
|
||||
out = []
|
||||
for member in group.members:
|
||||
with_inn = member.code not in group.no_inn
|
||||
for account in ACCOUNTS:
|
||||
for period in (periods[0], periods[-1]):
|
||||
data = osv.rows(books[member.code], account, period, member, group)
|
||||
path = root / f"{member.code}_{period.key}_{osv.file_name(account, period)}"
|
||||
osv.write(path, member, account, period, data, with_inn=with_inn)
|
||||
out.append((member, account, period, data, with_inn, _run(path)))
|
||||
return out
|
||||
|
||||
|
||||
def test_parser_status_and_account(parsed):
|
||||
bad = []
|
||||
for member, account, period, _, _, res in parsed:
|
||||
docs = res.get("documents", [])
|
||||
if res.get("status") != "done" or len(docs) != 1 or docs[0]["header"]["account_code_raw"] != account:
|
||||
bad.append((member.code, account, period.key, res.get("status"), res.get("error")))
|
||||
assert not bad, bad[:10]
|
||||
|
||||
|
||||
def test_parser_inn(parsed, osv_world):
|
||||
_, group, _, _ = osv_world
|
||||
group_names = {m.name_1c: m.inn for m in group.members}
|
||||
bad = []
|
||||
for member, account, period, data, with_inn, res in parsed:
|
||||
got = [r for r in res["documents"][0]["rows"] if r["group_type"] == "COUNTERPARTY" and r["data_type"] == "BU"]
|
||||
if with_inn:
|
||||
for r in got:
|
||||
if r["counterparty_name"] in group_names and r["counterparty_inn"] != group_names[r["counterparty_name"]]:
|
||||
bad.append((member.code, account, r["counterparty_name"], r["counterparty_inn"]))
|
||||
elif any(r["counterparty_inn"] for r in got):
|
||||
bad.append((member.code, account, "ИНН у ОСВ без ИНН"))
|
||||
assert not bad, bad[:10]
|
||||
|
||||
|
||||
def test_parser_totals(parsed):
|
||||
"""Сумма корневой строки счёта в документе = «Итого» листа = суммам `rows`; проверки парсера чистые."""
|
||||
keys = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
|
||||
cent = Decimal("0.01")
|
||||
bad = []
|
||||
for member, account, period, data, _, res in parsed:
|
||||
doc = res["documents"][0]
|
||||
checks = doc["checks"]
|
||||
if not checks["total_row_matches_root"] or checks["parent_sum_violations"] or checks["balance_identity_violations"]:
|
||||
bad.append((member.code, account, period.key, checks))
|
||||
root = next(r for r in doc["rows"] if r["group_type"] == "ACCOUNT" and r["data_type"] == "BU")
|
||||
got = tuple(Decimal(str(root[k] or 0)).quantize(cent) for k in keys)
|
||||
if got != tuple(v.quantize(cent) for v in osv.total_of(data)):
|
||||
bad.append((member.code, account, period.key, got))
|
||||
assert not bad, bad[:10]
|
||||
Loading…
x
Reference in New Issue
Block a user