synthgen: ОСВ в оформлении 1С (#38)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-13 00:20:44 +04:00
parent 8f1b4a7312
commit 6fb42be8ff
8 changed files with 7816 additions and 0 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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"])

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