v1.0.178: генератор тестовых файлов (схема Опуса). 61 файл: 10 контрактов × (договор+спека v1+v2+допники) + ошибки + ZIP + дубликаты. Комментарий в каждом файле.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
corrupt.py — Порча docx/pdf, конвертация в .doc.
|
||||||
|
|
||||||
|
Схема Опуса: 17 типов ошибочных кейсов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import zipfile, io, os, shutil, tempfile, subprocess
|
||||||
|
|
||||||
|
def corrupt_xml(docx_path, out_path):
|
||||||
|
"""
|
||||||
|
Битый XML внутри docx: обрезать тег в word/document.xml.
|
||||||
|
Парсер должен вернуть ✗, pipeline не падает.
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(docx_path, 'r') as zin:
|
||||||
|
data = {name: zin.read(name) for name in zin.namelist()}
|
||||||
|
|
||||||
|
# Найти document.xml и обрезать последние 200 байт
|
||||||
|
doc_key = 'word/document.xml'
|
||||||
|
if doc_key in data:
|
||||||
|
xml = data[doc_key]
|
||||||
|
# Обрезать закрывающие теги
|
||||||
|
cut = max(len(xml) - 500, len(xml) // 2)
|
||||||
|
data[doc_key] = xml[:cut]
|
||||||
|
|
||||||
|
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for name, content in data.items():
|
||||||
|
zout.writestr(name, content)
|
||||||
|
|
||||||
|
def make_empty_docx(out_path):
|
||||||
|
"""Пустой docx — без текста, без таблиц."""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document()
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def make_empty_file(out_path):
|
||||||
|
"""Файл нулевого размера."""
|
||||||
|
with open(out_path, 'wb') as f:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def make_text_only_docx(out_path, title="Соглашение об услугах"):
|
||||||
|
"""Docx только с текстом, без таблиц. Крайний случай для парсера."""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document()
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run(title).bold = True
|
||||||
|
doc.add_paragraph("Настоящий документ является соглашением об оказании услуг.")
|
||||||
|
doc.add_paragraph("Стороны: Заказчик и Исполнитель.")
|
||||||
|
doc.add_paragraph("Стоимость услуг: 100 000 рублей.")
|
||||||
|
doc.add_paragraph("Дата: 01.01.2026")
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def make_table_only_docx(out_path):
|
||||||
|
"""Docx только с таблицей, без параграфов. Крайний случай для парсера."""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document()
|
||||||
|
table = doc.add_table(rows=3, cols=6)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
headers = ["№", "Наименование", "Цена", "Объем", "Сумма", "Дата"]
|
||||||
|
for i, h in enumerate(headers):
|
||||||
|
table.rows[0].cells[i].text = h
|
||||||
|
table.rows[1].cells[0].text = "1"
|
||||||
|
table.rows[1].cells[1].text = "Тестовая услуга"
|
||||||
|
table.rows[1].cells[2].text = "1000,00"
|
||||||
|
table.rows[1].cells[3].text = "2"
|
||||||
|
table.rows[1].cells[4].text = "2000,00"
|
||||||
|
table.rows[1].cells[5].text = "01.06.2026"
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def make_no_number_docx(out_path, template_builder, ctx):
|
||||||
|
"""
|
||||||
|
Документ БЕЗ номера договора в тексте.
|
||||||
|
classify должен вернуть ошибку или 'other'.
|
||||||
|
"""
|
||||||
|
doc = template_builder(ctx)
|
||||||
|
# Удаляем параграф с номером (второй параграф после заголовка)
|
||||||
|
# Простой подход: заменяем текст номера на пробел
|
||||||
|
for p in doc.paragraphs:
|
||||||
|
if ctx["number"] in p.text:
|
||||||
|
p.text = p.text.replace(f"№ {ctx['number']}", "№ ")
|
||||||
|
break
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def make_nonstandard_title_docx(out_path):
|
||||||
|
"""
|
||||||
|
Документ с нестандартным заголовком — classify должен классифицировать
|
||||||
|
по содержимому, а не по заголовку.
|
||||||
|
"""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document()
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run("Соглашение о предоставлении технологических услуг").bold = True
|
||||||
|
doc.add_paragraph("№ 99999")
|
||||||
|
doc.add_paragraph('ООО "Нестандарт" и ООО "НУБЕС" заключили настоящее соглашение.')
|
||||||
|
doc.add_paragraph("Предмет: оказание услуг ЦОД.")
|
||||||
|
# Таблица услуг
|
||||||
|
table = doc.add_table(rows=2, cols=6)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
for i, h in enumerate(["№", "Наименование", "Цена", "Объем", "Сумма", "Дата"]):
|
||||||
|
table.rows[0].cells[i].text = h
|
||||||
|
table.rows[1].cells[0].text = "1"
|
||||||
|
table.rows[1].cells[1].text = "Аренда стойко-места"
|
||||||
|
table.rows[1].cells[2].text = "200 000,00"
|
||||||
|
table.rows[1].cells[3].text = "2"
|
||||||
|
table.rows[1].cells[4].text = "400 000,00"
|
||||||
|
table.rows[1].cells[5].text = "01.06.2026"
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def make_giant_docx(out_path, rows=500):
|
||||||
|
"""Документ с гигантской таблицей — тест производительности."""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document()
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run("Договор № GIANT").bold = True
|
||||||
|
doc.add_paragraph("Спецификация с большим количеством позиций.")
|
||||||
|
table = doc.add_table(rows=1, cols=6)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
for i, h in enumerate(["№", "Наименование", "Цена", "Объем", "Сумма", "Дата"]):
|
||||||
|
table.rows[0].cells[i].text = h
|
||||||
|
for r in range(rows):
|
||||||
|
row = table.add_row()
|
||||||
|
row.cells[0].text = str(r + 1)
|
||||||
|
row.cells[1].text = f"Услуга {r+1}"
|
||||||
|
row.cells[2].text = f"{1000 + r * 10},00"
|
||||||
|
row.cells[3].text = "1"
|
||||||
|
row.cells[4].text = f"{1000 + r * 10},00"
|
||||||
|
row.cells[5].text = "01.06.2026"
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def make_trash_docx(out_path, label="Счёт-фактура"):
|
||||||
|
"""Мусорный документ — не договор. Негативная классификация."""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document()
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run(label).bold = True
|
||||||
|
doc.add_paragraph("№ СФ-2026-001 от 15.06.2026")
|
||||||
|
doc.add_paragraph("Поставщик: ООО Рога и Копыта")
|
||||||
|
doc.add_paragraph("Покупатель: ЗАО XXX001")
|
||||||
|
doc.add_paragraph("Сумма: 150 000,00 руб.")
|
||||||
|
doc.save(out_path)
|
||||||
|
|
||||||
|
def convert_to_doc(docx_path, out_path):
|
||||||
|
"""Конвертировать docx → doc через LibreOffice."""
|
||||||
|
out_dir = os.path.dirname(out_path) or "."
|
||||||
|
out_name = os.path.basename(out_path)
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
shutil.copy(docx_path, os.path.join(tmp, "temp.docx"))
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["libreoffice", "--headless", "--convert-to", "doc", "--outdir", tmp,
|
||||||
|
os.path.join(tmp, "temp.docx")],
|
||||||
|
timeout=30, capture_output=True
|
||||||
|
)
|
||||||
|
src = os.path.join(tmp, "temp.doc")
|
||||||
|
if os.path.exists(src):
|
||||||
|
shutil.move(src, out_path)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def make_broken_pdf(docx_path, out_path):
|
||||||
|
"""Сгенерировать PDF из docx и обрезать байты — битый PDF."""
|
||||||
|
# Сначала генерируем нормальный PDF через LibreOffice
|
||||||
|
out_dir = os.path.dirname(out_path) or "."
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
shutil.copy(docx_path, os.path.join(tmp, "temp.docx"))
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmp,
|
||||||
|
os.path.join(tmp, "temp.docx")],
|
||||||
|
timeout=30, capture_output=True
|
||||||
|
)
|
||||||
|
src = os.path.join(tmp, "temp.pdf")
|
||||||
|
if os.path.exists(src):
|
||||||
|
with open(src, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
# Обрезать последние 30% байт
|
||||||
|
cut = int(len(data) * 0.7)
|
||||||
|
with open(out_path, 'wb') as f:
|
||||||
|
f.write(data[:cut])
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
return False
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""
|
||||||
|
generate.py — Оркестратор генерации ~100 тестовых файлов.
|
||||||
|
|
||||||
|
Схема Опуса: 10 контрактов × (договор + спека v1 + спека v2 + 1-3 допника)
|
||||||
|
+ ошибочные, форматы, ZIP, дубликаты, мусор.
|
||||||
|
|
||||||
|
Вывод: out/ с подпапками + manifest.json (ground truth).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, json, random, shutil, zipfile
|
||||||
|
import pools
|
||||||
|
import templates
|
||||||
|
import corrupt
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__), "out")
|
||||||
|
MANIFEST = {}
|
||||||
|
|
||||||
|
def ensure_dir(path):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
def save_doc(doc, path):
|
||||||
|
doc.save(path)
|
||||||
|
print(f" ✓ {os.path.basename(path)}")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Генерация нормальных контрактов
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def generate_valid():
|
||||||
|
"""10 контрактов: договор + спека v1 + спека v2 + 1-3 допника."""
|
||||||
|
out = os.path.join(OUT, "valid")
|
||||||
|
ensure_dir(out)
|
||||||
|
|
||||||
|
for ci, company in enumerate(pools.COMPANIES[:10]):
|
||||||
|
num = pools.contract_number(ci)
|
||||||
|
date = pools.random_date(2025, 12, 7)
|
||||||
|
prefix = company["prefix"]
|
||||||
|
|
||||||
|
# ── Договор ──
|
||||||
|
ctx_contract = {
|
||||||
|
"company": company, "number": num, "date": date,
|
||||||
|
"company_idx": ci,
|
||||||
|
"comment": (
|
||||||
|
f"Базовый договор контракта {prefix}-{num}. "
|
||||||
|
f"К нему привязаны спецификации и допсоглашения через НОМЕР={num}."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fname = f"договор-{prefix}-{num}.docx"
|
||||||
|
fpath = os.path.join(out, fname)
|
||||||
|
save_doc(templates.build_contract(ctx_contract), fpath)
|
||||||
|
MANIFEST[fname] = {
|
||||||
|
"type": "contract", "company": prefix, "number": num,
|
||||||
|
"date": date, "status": "valid",
|
||||||
|
"groups": [{"contract_number": num, "counterparty": company["prefix"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Спецификация v1 ──
|
||||||
|
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(random.randint(5, 9))
|
||||||
|
# Присвоить даты из пула
|
||||||
|
for s in svcs_v1:
|
||||||
|
s["date_start"] = pools.random_date(2026, 3, 3)
|
||||||
|
|
||||||
|
ctx_spec_v1 = {
|
||||||
|
"company": company, "number": num, "date": date,
|
||||||
|
"contract_date": date, "version": 1,
|
||||||
|
"services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1,
|
||||||
|
"label": f"Абонентские услуги с {date}",
|
||||||
|
"comment": (
|
||||||
|
f"Спецификация v1 (базовая) к договору {num}. "
|
||||||
|
f"{len(svcs_v1)} услуг, итого {tot_str_v1} руб."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fname_v1 = f"спецификация-{prefix}-{num}.docx"
|
||||||
|
fpath_v1 = os.path.join(out, fname_v1)
|
||||||
|
save_doc(templates.build_spec(ctx_spec_v1), fpath_v1)
|
||||||
|
MANIFEST[fname_v1] = {
|
||||||
|
"type": "spec", "company": prefix, "number": num,
|
||||||
|
"version": 1, "date": date, "status": "valid",
|
||||||
|
"services": [{"name": s["name"], "price": s["price"], "qty": s["qty"], "sum": s["sum"]} for s in svcs_v1],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Спецификация v2 (ревизия с diff-ами) ──
|
||||||
|
svcs_v2, changes = _make_v2(svcs_v1)
|
||||||
|
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
|
||||||
|
tot_str_v2 = pools.format_price(tot_v2)
|
||||||
|
# Определить diff
|
||||||
|
diffs = _compute_diff(svcs_v1, svcs_v2)
|
||||||
|
# Добавить changes в описание
|
||||||
|
if changes:
|
||||||
|
diffs["description"] = ", ".join(changes)
|
||||||
|
|
||||||
|
ctx_spec_v2 = {
|
||||||
|
"company": company, "number": num, "date": date,
|
||||||
|
"contract_date": date, "version": 2,
|
||||||
|
"services": svcs_v2, "total": tot_v2, "total_str": tot_str_v2,
|
||||||
|
"label": f"Абонентские услуги с {pools.random_date(2026, 4, 3)}",
|
||||||
|
"comment": (
|
||||||
|
f"Спецификация v2 (ревизия) к договору {num}. "
|
||||||
|
f"Изменения относительно v1: {diffs['description']}. "
|
||||||
|
f"{len(svcs_v2)} услуг, итого {tot_str_v2} руб."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fname_v2 = f"спецификация-{prefix}-{num}_v2.docx"
|
||||||
|
fpath_v2 = os.path.join(out, fname_v2)
|
||||||
|
save_doc(templates.build_spec(ctx_spec_v2), fpath_v2)
|
||||||
|
MANIFEST[fname_v2] = {
|
||||||
|
"type": "spec", "company": prefix, "number": num,
|
||||||
|
"version": 2, "date": date, "status": "valid",
|
||||||
|
"services": [{"name": s["name"], "price": s["price"], "qty": s["qty"], "sum": s["sum"]} for s in svcs_v2],
|
||||||
|
"diff": diffs,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Допники (1-3) ──
|
||||||
|
n_add = random.randint(1, 3)
|
||||||
|
for ai in range(1, n_add + 1):
|
||||||
|
inst, itot, itot_s = pools.pick_services(random.randint(1, 3))
|
||||||
|
mon, mtot, mtot_s = pools.pick_services(random.randint(2, 5))
|
||||||
|
adate = pools.random_date(2026, 1, 6)
|
||||||
|
ctx_add = {
|
||||||
|
"company": company, "number": num, "date": adate,
|
||||||
|
"contract_date": date, "addendum_num": ai,
|
||||||
|
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
|
||||||
|
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
|
||||||
|
"comment": (
|
||||||
|
f"Допсоглашение №{ai} к договору {num}. "
|
||||||
|
f"Инсталляц.: {len(inst)} стр., {itot_s} руб. "
|
||||||
|
f"Абонентск.: {len(mon)} стр., {mtot_s} руб."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fname_add = f"допник-{ai}-{prefix}-{num}.docx"
|
||||||
|
fpath_add = os.path.join(out, fname_add)
|
||||||
|
save_doc(templates.build_addendum(ctx_add), fpath_add)
|
||||||
|
MANIFEST[fname_add] = {
|
||||||
|
"type": "addendum", "company": prefix, "number": num,
|
||||||
|
"addendum_num": ai, "date": adate, "status": "valid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_v2(svcs_v1):
|
||||||
|
"""
|
||||||
|
Создать v2 из v1 с преднамеренными diff-ами:
|
||||||
|
- Изменить цену у 1-2 услуг (±15-30%)
|
||||||
|
- Изменить объём у 1 услуги
|
||||||
|
- Удалить 1 услугу (если > 3)
|
||||||
|
- Добавить 1 новую услугу
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
svcs = copy.deepcopy(svcs_v1)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
# Изменить цену
|
||||||
|
if len(svcs) >= 1:
|
||||||
|
idx = random.randint(0, len(svcs) - 1)
|
||||||
|
old_price = svcs[idx]["price"]
|
||||||
|
new_price = round(old_price * random.uniform(0.70, 1.30), 2)
|
||||||
|
svcs[idx]["price"] = new_price
|
||||||
|
svcs[idx]["price_str"] = pools.format_price(new_price)
|
||||||
|
svcs[idx]["sum"] = round(new_price * svcs[idx]["qty"], 2)
|
||||||
|
svcs[idx]["sum_str"] = pools.format_price(svcs[idx]["sum"])
|
||||||
|
changes.append(f"цена {svcs[idx]['name'][:40]} {old_price}→{new_price}")
|
||||||
|
|
||||||
|
# Изменить объём
|
||||||
|
if len(svcs) >= 2:
|
||||||
|
idx = random.randint(0, len(svcs) - 1)
|
||||||
|
old_qty = svcs[idx]["qty"]
|
||||||
|
new_qty = old_qty + random.choice([-2, -1, 1, 2, 3])
|
||||||
|
if new_qty >= 1:
|
||||||
|
svcs[idx]["qty"] = new_qty
|
||||||
|
svcs[idx]["sum"] = round(svcs[idx]["price"] * new_qty, 2)
|
||||||
|
svcs[idx]["sum_str"] = pools.format_price(svcs[idx]["sum"])
|
||||||
|
changes.append(f"объём {svcs[idx]['name'][:40]} {old_qty}→{new_qty}")
|
||||||
|
|
||||||
|
# Удалить услугу
|
||||||
|
if len(svcs) > 3:
|
||||||
|
idx = random.randint(0, len(svcs) - 1)
|
||||||
|
removed = svcs.pop(idx)
|
||||||
|
changes.append(f"удалена: {removed['name'][:40]}")
|
||||||
|
|
||||||
|
# Добавить новую услугу
|
||||||
|
new_svc_pool = [s for s in pools.SERVICES if not any(s[0] == x["name"] for x in svcs)]
|
||||||
|
if new_svc_pool:
|
||||||
|
name, base_price, min_q, max_q, cat = random.choice(new_svc_pool)
|
||||||
|
price = pools.vary_price(base_price)
|
||||||
|
qty = pools.vary_qty(min_q, max_q)
|
||||||
|
s = round(price * qty, 2)
|
||||||
|
svcs.append({
|
||||||
|
"num": len(svcs) + 1,
|
||||||
|
"name": name, "price": price, "price_str": pools.format_price(price),
|
||||||
|
"qty": qty, "sum": s, "sum_str": pools.format_price(s), "cat": cat,
|
||||||
|
"date_start": pools.random_date(2026, 4, 3),
|
||||||
|
})
|
||||||
|
changes.append(f"добавлена: {name[:40]}")
|
||||||
|
|
||||||
|
# Пересчитать номера
|
||||||
|
for i, s in enumerate(svcs, 1):
|
||||||
|
s["num"] = i
|
||||||
|
|
||||||
|
return svcs, changes
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_diff(v1, v2):
|
||||||
|
"""Вычислить разницу между v1 и v2 для manifest.json."""
|
||||||
|
names_v1 = {s["name"] for s in v1}
|
||||||
|
names_v2 = {s["name"] for s in v2}
|
||||||
|
added = [s["name"][:60] for s in v2 if s["name"] not in names_v1]
|
||||||
|
removed = [s["name"][:60] for s in v1 if s["name"] not in names_v2]
|
||||||
|
changed = []
|
||||||
|
for s1 in v1:
|
||||||
|
for s2 in v2:
|
||||||
|
if s1["name"] == s2["name"]:
|
||||||
|
if s1["price"] != s2["price"]:
|
||||||
|
changed.append(f"цена {s1['name'][:40]}: {s1['price']}→{s2['price']}")
|
||||||
|
if s1["qty"] != s2["qty"]:
|
||||||
|
changed.append(f"объём {s1['name'][:40]}: {s1['qty']}→{s2['qty']}")
|
||||||
|
|
||||||
|
desc_parts = []
|
||||||
|
if added: desc_parts.append(f"+{len(added)} услуг")
|
||||||
|
if removed: desc_parts.append(f"-{len(removed)} услуг")
|
||||||
|
if changed: desc_parts.append(f"~{len(changed)} изменений")
|
||||||
|
description = ", ".join(desc_parts) if desc_parts else "без изменений"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"added": added, "removed": removed, "changed": changed,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Ошибочные кейсы
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def generate_errors():
|
||||||
|
out = os.path.join(OUT, "errors")
|
||||||
|
ensure_dir(out)
|
||||||
|
|
||||||
|
ctx_tmp = {
|
||||||
|
"company": pools.COMPANIES[0], "number": "ERR01",
|
||||||
|
"date": "01.06.2026", "company_idx": 0,
|
||||||
|
"comment": "Ошибочный кейс."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Битый XML
|
||||||
|
tmp = os.path.join(out, "_tmp_valid.docx")
|
||||||
|
templates.build_contract(ctx_tmp).save(tmp)
|
||||||
|
corrupt.corrupt_xml(tmp, os.path.join(out, "corrupt_xml.docx"))
|
||||||
|
MANIFEST["corrupt_xml.docx"] = {"type": "error", "error": "corrupt_xml", "status": "expected_fail"}
|
||||||
|
|
||||||
|
# Пустой
|
||||||
|
corrupt.make_empty_docx(os.path.join(out, "empty.docx"))
|
||||||
|
MANIFEST["empty.docx"] = {"type": "error", "error": "empty_docx", "status": "expected_fail"}
|
||||||
|
|
||||||
|
corrupt.make_empty_file(os.path.join(out, "empty_zero.docx"))
|
||||||
|
MANIFEST["empty_zero.docx"] = {"type": "error", "error": "zero_bytes", "status": "expected_fail"}
|
||||||
|
|
||||||
|
# Только текст
|
||||||
|
corrupt.make_text_only_docx(os.path.join(out, "text_only.docx"))
|
||||||
|
MANIFEST["text_only.docx"] = {"type": "error", "error": "text_only", "status": "expected_parse_ok_no_tables"}
|
||||||
|
|
||||||
|
# Только таблицы
|
||||||
|
corrupt.make_table_only_docx(os.path.join(out, "table_only.docx"))
|
||||||
|
MANIFEST["table_only.docx"] = {"type": "error", "error": "table_only", "status": "expected_parse_ok"}
|
||||||
|
|
||||||
|
# Без номера
|
||||||
|
corrupt.make_no_number_docx(os.path.join(out, "no_number.docx"), templates.build_contract, ctx_tmp)
|
||||||
|
MANIFEST["no_number.docx"] = {"type": "error", "error": "no_number", "status": "expected_classify_fail"}
|
||||||
|
|
||||||
|
# Нестандартный заголовок
|
||||||
|
corrupt.make_nonstandard_title_docx(os.path.join(out, "nonstandard_title.docx"))
|
||||||
|
MANIFEST["nonstandard_title.docx"] = {"type": "error", "error": "nonstandard_title", "status": "expected_classify_ok"}
|
||||||
|
|
||||||
|
# Гигант
|
||||||
|
corrupt.make_giant_docx(os.path.join(out, "giant_500.docx"), 500)
|
||||||
|
MANIFEST["giant_500.docx"] = {"type": "error", "error": "giant", "status": "expected_slow"}
|
||||||
|
|
||||||
|
# Мусор (3 шт.)
|
||||||
|
trash_types = ["Счёт-фактура", "Письмо-запрос", "Коммерческое предложение"]
|
||||||
|
for i, tt in enumerate(trash_types):
|
||||||
|
fname = f"trash_{i+1}.docx"
|
||||||
|
corrupt.make_trash_docx(os.path.join(out, fname), tt)
|
||||||
|
MANIFEST[fname] = {"type": "trash", "label": tt, "status": "expected_classify_other"}
|
||||||
|
|
||||||
|
# Сирота — спецификация с номером, для которого нет договора
|
||||||
|
ctx_orphan = {
|
||||||
|
"company": pools.COMPANIES[0], "number": "ORPHAN",
|
||||||
|
"date": "01.06.2026", "contract_date": "01.01.2026", "version": 1,
|
||||||
|
"services": pools.pick_services(3)[0],
|
||||||
|
"total": 0, "total_str": "0",
|
||||||
|
"label": "Абонентские услуги",
|
||||||
|
"comment": "Спецификация-сирота: номер ORPHAN не принадлежит ни одному договору."
|
||||||
|
}
|
||||||
|
ctx_orphan["total"] = round(sum(s["sum"] for s in ctx_orphan["services"]), 2)
|
||||||
|
ctx_orphan["total_str"] = pools.format_price(ctx_orphan["total"])
|
||||||
|
save_doc(templates.build_spec(ctx_orphan), os.path.join(out, "orphan_spec.docx"))
|
||||||
|
MANIFEST["orphan_spec.docx"] = {"type": "error", "error": "orphan", "status": "expected_group_unresolved"}
|
||||||
|
|
||||||
|
print(f" Сгенерировано ошибочных: 13")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# .doc и .pdf форматы
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def generate_formats():
|
||||||
|
out = os.path.join(OUT, "formats")
|
||||||
|
ensure_dir(out)
|
||||||
|
|
||||||
|
# Проверить наличие LibreOffice
|
||||||
|
import subprocess as sp
|
||||||
|
has_lo = False
|
||||||
|
try:
|
||||||
|
sp.run(["libreoffice", "--version"], capture_output=True, timeout=5)
|
||||||
|
has_lo = True
|
||||||
|
except (FileNotFoundError, sp.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not has_lo:
|
||||||
|
print(" ⚠ LibreOffice не найден — .doc/.pdf пропущены")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Взять один валидный контракт как основу
|
||||||
|
ci = 0; c = pools.COMPANIES[ci]; num = pools.contract_number(ci); date = pools.random_date()
|
||||||
|
ctx = {"company": c, "number": num, "date": date, "company_idx": ci,
|
||||||
|
"comment": "Конвертирован в .doc / .pdf для теста."}
|
||||||
|
|
||||||
|
docx_path = os.path.join(out, "_base.docx")
|
||||||
|
templates.build_contract(ctx).save(docx_path)
|
||||||
|
|
||||||
|
# .doc
|
||||||
|
doc_path = os.path.join(out, f"договор-{c['prefix']}-{num}.doc")
|
||||||
|
ok = corrupt.convert_to_doc(docx_path, doc_path)
|
||||||
|
if ok:
|
||||||
|
print(f" ✓ .doc конвертирован: {os.path.basename(doc_path)}")
|
||||||
|
MANIFEST[os.path.basename(doc_path)] = {"type": "contract", "format": "doc", "status": "valid"}
|
||||||
|
else:
|
||||||
|
print(f" ⚠ .doc конвертация не удалась (нет LibreOffice?)")
|
||||||
|
|
||||||
|
# Нормальный PDF
|
||||||
|
pdf_path = os.path.join(out, f"договор-{c['prefix']}-{num}.pdf")
|
||||||
|
corrupt.make_broken_pdf(docx_path, pdf_path) # эта функция делает битый — используем её, но с полным файлом
|
||||||
|
# На самом деле make_broken_pdf обрезает. Сделаем нормальный отдельно.
|
||||||
|
# Перезапишем полным — make_broken_pdf уже обрезал, сделаем заново с 100%
|
||||||
|
import tempfile, subprocess as sp
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
shutil.copy(docx_path, os.path.join(tmp, "t.docx"))
|
||||||
|
sp.run(["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmp, os.path.join(tmp, "t.docx")],
|
||||||
|
timeout=30, capture_output=True)
|
||||||
|
src = os.path.join(tmp, "t.pdf")
|
||||||
|
if os.path.exists(src):
|
||||||
|
shutil.move(src, pdf_path)
|
||||||
|
print(f" ✓ .pdf: {os.path.basename(pdf_path)}")
|
||||||
|
MANIFEST[os.path.basename(pdf_path)] = {"type": "contract", "format": "pdf", "status": "valid"}
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# Битый PDF
|
||||||
|
broken_pdf = os.path.join(out, f"broken.pdf")
|
||||||
|
corrupt.make_broken_pdf(docx_path, broken_pdf)
|
||||||
|
if os.path.exists(broken_pdf):
|
||||||
|
print(f" ✓ битый .pdf: {os.path.basename(broken_pdf)}")
|
||||||
|
MANIFEST[os.path.basename(broken_pdf)] = {"type": "error", "error": "broken_pdf", "status": "expected_fail"}
|
||||||
|
|
||||||
|
os.remove(docx_path) # убрать временный
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# ZIP-архивы
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def generate_zips():
|
||||||
|
out = os.path.join(OUT, "zips")
|
||||||
|
ensure_dir(out)
|
||||||
|
|
||||||
|
valid_dir = os.path.join(OUT, "valid")
|
||||||
|
if not os.path.exists(valid_dir):
|
||||||
|
print(" ⚠ valid/ не найден, ZIP-ы не сгенерированы")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Набор файлов одного контракта
|
||||||
|
files_for_zip = [f for f in os.listdir(valid_dir) if f.startswith("договор-XXX001")]
|
||||||
|
if files_for_zip:
|
||||||
|
zip_path = os.path.join(out, "contract_XXX001.zip")
|
||||||
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f in files_for_zip:
|
||||||
|
zf.write(os.path.join(valid_dir, f), f)
|
||||||
|
print(f" ✓ ZIP: {os.path.basename(zip_path)} ({len(files_for_zip)} файлов)")
|
||||||
|
MANIFEST[os.path.basename(zip_path)] = {"type": "zip", "files": files_for_zip, "status": "valid"}
|
||||||
|
|
||||||
|
# ZIP с дубликатами имён внутри
|
||||||
|
if len(files_for_zip) >= 2:
|
||||||
|
zip_path2 = os.path.join(out, "duplicates_inside.zip")
|
||||||
|
with zipfile.ZipFile(zip_path2, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
|
||||||
|
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0]) # дубликат!
|
||||||
|
print(f" ✓ ZIP с дубликатами: {os.path.basename(zip_path2)}")
|
||||||
|
MANIFEST[os.path.basename(zip_path2)] = {"type": "zip", "duplicates": True, "status": "valid"}
|
||||||
|
|
||||||
|
# ZIP с битым файлом внутри
|
||||||
|
err_dir = os.path.join(OUT, "errors")
|
||||||
|
if os.path.exists(err_dir) and files_for_zip:
|
||||||
|
zip_path3 = os.path.join(out, "mixed_with_error.zip")
|
||||||
|
with zipfile.ZipFile(zip_path3, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
|
||||||
|
zf.write(os.path.join(err_dir, "corrupt_xml.docx"), "corrupt_xml.docx")
|
||||||
|
print(f" ✓ ZIP с битым: {os.path.basename(zip_path3)}")
|
||||||
|
MANIFEST[os.path.basename(zip_path3)] = {"type": "zip", "has_error": True, "status": "valid"}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Дубликаты
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def generate_duplicates():
|
||||||
|
out = os.path.join(OUT, "duplicates")
|
||||||
|
ensure_dir(out)
|
||||||
|
|
||||||
|
valid_dir = os.path.join(OUT, "valid")
|
||||||
|
if not os.path.exists(valid_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
files = [f for f in os.listdir(valid_dir) if f.endswith('.docx')]
|
||||||
|
if len(files) >= 3:
|
||||||
|
# Точная копия (другое имя)
|
||||||
|
src = os.path.join(valid_dir, files[0])
|
||||||
|
shutil.copy(src, os.path.join(out, "copy_of_" + files[0]))
|
||||||
|
MANIFEST["copy_of_" + files[0]] = {"type": "duplicate", "original": files[0], "kind": "content_copy"}
|
||||||
|
|
||||||
|
# Одинаковое имя, разный контент
|
||||||
|
shutil.copy(os.path.join(valid_dir, files[1]), os.path.join(out, files[0]))
|
||||||
|
MANIFEST[files[0]] = {"type": "duplicate", "original": files[0], "kind": "name_collision"}
|
||||||
|
|
||||||
|
print(f" ✓ Дубликаты: 2 шт.")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Main
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("Генератор тестовых документов (схема Опуса)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print("\n── Валидные контракты ──")
|
||||||
|
generate_valid()
|
||||||
|
|
||||||
|
print("\n── Ошибочные кейсы ──")
|
||||||
|
generate_errors()
|
||||||
|
|
||||||
|
print("\n── Форматы (.doc/.pdf) ──")
|
||||||
|
generate_formats()
|
||||||
|
|
||||||
|
print("\n── ZIP-архивы ──")
|
||||||
|
generate_zips()
|
||||||
|
|
||||||
|
print("\n── Дубликаты ──")
|
||||||
|
generate_duplicates()
|
||||||
|
|
||||||
|
# Сохранить manifest
|
||||||
|
manifest_path = os.path.join(OUT, "manifest.json")
|
||||||
|
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(MANIFEST, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
total = len(MANIFEST)
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"Всего файлов в manifest.json: {total}")
|
||||||
|
print(f"Вывод: {OUT}/")
|
||||||
|
print(f"Манифест: {manifest_path}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
pools.py — Пулы данных для генератора тестовых документов.
|
||||||
|
|
||||||
|
Схема Опуса: 10 компаний, пул из ~12 услуг, цены ±20% от базовых,
|
||||||
|
вариативность дат, номеров, названий.
|
||||||
|
|
||||||
|
Все данные основаны на реальных файлах из примеры_договоров_для_ИИ/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
# ── Компании ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COMPANIES = [
|
||||||
|
{"prefix": "XXX001", "legal": 'ЗАО "XXX001"', "dir": "Генерального директора"},
|
||||||
|
{"prefix": "XXX002", "legal": 'ООО "XXX002"', "dir": "Генерального директора"},
|
||||||
|
{"prefix": "XXX003", "legal": 'АО "XXX003"', "dir": "Директора"},
|
||||||
|
{"prefix": "XXX004", "legal": 'ПАО "XXX004"', "dir": "Генерального директора"},
|
||||||
|
{"prefix": "XXX005", "legal": 'ООО "XXX005"', "dir": "Директора"},
|
||||||
|
{"prefix": "XXX006", "legal": 'ЗАО "XXX006"', "dir": "Генерального директора"},
|
||||||
|
{"prefix": "XXX007", "legal": 'АО "XXX007"', "dir": "Директора"},
|
||||||
|
{"prefix": "XXX008", "legal": 'ООО "XXX008"', "dir": "Генерального директора"},
|
||||||
|
{"prefix": "XXX009", "legal": 'ИП "XXX009"', "dir": "Индивидуального предпринимателя"},
|
||||||
|
{"prefix": "XXX010", "legal": 'ПАО "XXX010"', "dir": "Генерального директора"},
|
||||||
|
# Кириллический edge (уже есть в примерах)
|
||||||
|
{"prefix": "ХХХ002", "legal": 'ООО "ХХХ002"', "dir": "Генерального директора"},
|
||||||
|
]
|
||||||
|
|
||||||
|
PROVIDER = 'ООО "НУБЕС"'
|
||||||
|
PROVIDER_DIR = "Генерального директора Степаненко В.И."
|
||||||
|
CITY = "г. Москва"
|
||||||
|
ADDRESS = "115404, г. Москва, 1-я Стекольная 7с5"
|
||||||
|
|
||||||
|
# ── Номера договоров ─────────────────────────────────────────
|
||||||
|
|
||||||
|
# Базовые номера из примеров + вариации
|
||||||
|
BASE_NUMBERS = ["03700", "01200", "01300", "04100", "05200", "06800", "07400", "08100", "09900", "10500"]
|
||||||
|
|
||||||
|
def contract_number(company_idx, variant=0):
|
||||||
|
"""Сгенерировать номер договора: БАЗА[_суффикс]."""
|
||||||
|
base = BASE_NUMBERS[company_idx % len(BASE_NUMBERS)]
|
||||||
|
if variant > 0:
|
||||||
|
return f"{base}_{variant}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
# ── Даты ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def random_date(base_year=2025, base_month=12, spread_months=7):
|
||||||
|
"""Случайная дата ±spread_months от базовой. Возвращает DD.MM.YYYY."""
|
||||||
|
import datetime
|
||||||
|
base = datetime.date(base_year, base_month, 1)
|
||||||
|
offset = random.randint(0, spread_months * 30)
|
||||||
|
d = base + datetime.timedelta(days=offset)
|
||||||
|
return d.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
# ── Пул услуг и базовых цен (из реальных файлов) ────────────
|
||||||
|
|
||||||
|
# (наименование, базовая_цена, мин_объём, макс_объём, категория)
|
||||||
|
# категория: 'rack' | 'power' | 'network' | 'cloud' | 'ip' | 'cross'
|
||||||
|
|
||||||
|
SERVICES = [
|
||||||
|
("Аренда стойко-места", 213905.44, 1, 5, "rack"),
|
||||||
|
("Аренда PDU: 3Ф 32А вертикальный", 2511.17, 1, 5, "power"),
|
||||||
|
("Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", 11016.36, 1, 3, "network"),
|
||||||
|
("Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", 28300.00, 1, 2, "network"),
|
||||||
|
("Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", 76704.00, 1, 3, "cloud"),
|
||||||
|
("Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", 123823.00, 1, 2, "cloud"),
|
||||||
|
("Аренда IPv4 адреса /32", 157.28, 1, 16, "ip"),
|
||||||
|
("Аренда IPv4 подсети /30", 629.10, 1, 4, "ip"),
|
||||||
|
("Организация cross-connect: ВОЛС, 2×SMF", 4192.40, 1, 5, "cross"),
|
||||||
|
("Организация cross-connect: UTP", 840.94, 1, 5, "cross"),
|
||||||
|
("Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", 7015.00, 1, 3, "network"),
|
||||||
|
("Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", 1525.00, 1, 3, "network"),
|
||||||
|
("Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", 589.66, 1, 4, "ip"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Функции вариативности ────────────────────────────────────
|
||||||
|
|
||||||
|
def vary_price(base, pct=0.20):
|
||||||
|
"""Случайное изменение цены ±pct, округление до копеек."""
|
||||||
|
factor = 1.0 + random.uniform(-pct, pct)
|
||||||
|
return round(base * factor, 2)
|
||||||
|
|
||||||
|
def vary_qty(min_q, max_q):
|
||||||
|
return random.randint(min_q, max_q)
|
||||||
|
|
||||||
|
def format_price(p):
|
||||||
|
"""Формат цены с пробелами-разделителями: 213 905,44."""
|
||||||
|
s = f"{p:,.2f}"
|
||||||
|
# 213,905.44 → 213 905,44
|
||||||
|
return s.replace(",", " ").replace(".", ",").replace(" ", " ")
|
||||||
|
|
||||||
|
def pick_services(count=5, with_prices=True):
|
||||||
|
"""Выбрать случайный набор услуг. Возвращает [(name, price, qty, sum), ...]."""
|
||||||
|
selected = random.sample(SERVICES, min(count, len(SERVICES)))
|
||||||
|
result = []
|
||||||
|
total = 0.0
|
||||||
|
for i, (name, base_price, min_q, max_q, cat) in enumerate(selected, 1):
|
||||||
|
price = vary_price(base_price) if with_prices else base_price
|
||||||
|
qty = vary_qty(min_q, max_q)
|
||||||
|
s = round(price * qty, 2)
|
||||||
|
total += s
|
||||||
|
result.append({
|
||||||
|
"num": i,
|
||||||
|
"name": name,
|
||||||
|
"price": price,
|
||||||
|
"price_str": format_price(price),
|
||||||
|
"qty": qty,
|
||||||
|
"sum": s,
|
||||||
|
"sum_str": format_price(s),
|
||||||
|
"cat": cat,
|
||||||
|
})
|
||||||
|
return result, round(total, 2), format_price(round(total, 2))
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
templates.py — Шаблоны документов (python-docx).
|
||||||
|
|
||||||
|
build_contract(ctx) — Договор на оказание технологических услуг
|
||||||
|
build_spec(ctx) — Спецификация и стоимость услуг
|
||||||
|
build_addendum(ctx) — Дополнительное соглашение
|
||||||
|
|
||||||
|
Каждый документ начинается с параграфа-комментария:
|
||||||
|
«Данный файл: [тип], компания [X], номер [N], версия [V].
|
||||||
|
Содержит: [краткое описание]. Сгенерирован автоматически для тестирования.»
|
||||||
|
"""
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, Cm, RGBColor
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||||
|
import pools
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Хелперы
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _comment_para(doc, text):
|
||||||
|
"""Добавить параграф-комментарий в начало документа (серый, мелкий)."""
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.font.size = Pt(8)
|
||||||
|
run.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
|
||||||
|
run.font.italic = True
|
||||||
|
|
||||||
|
def _add_title(doc, text):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.bold = True
|
||||||
|
run.font.size = Pt(14)
|
||||||
|
|
||||||
|
def _add_heading(doc, text):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.bold = True
|
||||||
|
run.font.size = Pt(12)
|
||||||
|
|
||||||
|
def _add_para(doc, text):
|
||||||
|
p = doc.add_paragraph(text)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def _add_parties_table(doc, customer, provider, provider_dir, city, date):
|
||||||
|
"""Таблица реквизитов сторон (1×2): Заказчик | Исполнитель."""
|
||||||
|
table = doc.add_table(rows=1, cols=2)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
c0 = table.rows[0].cells[0]
|
||||||
|
c1 = table.rows[0].cells[1]
|
||||||
|
cust_info = f'{customer["legal"]}\nЮридический адрес:\n\n{customer.get("dir","")}\n_______________/ФИО/\n М.П.'
|
||||||
|
prov_info = f'{provider}\nЮридический адрес:\n{pools.ADDRESS}\n{provider_dir}\n_______________/Степаненко В.И./\n М.П.'
|
||||||
|
c0.text = f"Заказчик\n{cust_info}"
|
||||||
|
c1.text = f"Исполнитель\n{prov_info}"
|
||||||
|
|
||||||
|
def _add_services_table(doc, services, total_str, with_end_date=False, label="Абонентские услуги"):
|
||||||
|
"""Таблица услуг: №, Наименование, Цена, Объем, Сумма, Дата начала [+ Дата окончания] + Итого."""
|
||||||
|
_add_para(doc, f"{label}:")
|
||||||
|
cols = 7 if with_end_date else 6
|
||||||
|
header = ["№ п/п", "Наименование услуг", "Цена, руб. с НДС", "Объем услуг", "Сумма, руб. с НДС", "Планируемая дата начала оказания услуг"]
|
||||||
|
if with_end_date:
|
||||||
|
header.append("Дата окончания оказания услуг")
|
||||||
|
|
||||||
|
table = doc.add_table(rows=1, cols=cols)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
# Заголовок
|
||||||
|
for i, h in enumerate(header):
|
||||||
|
table.rows[0].cells[i].text = h
|
||||||
|
# Строки услуг
|
||||||
|
for s in services:
|
||||||
|
row = table.add_row()
|
||||||
|
cells = row.cells
|
||||||
|
cells[0].text = str(s["num"])
|
||||||
|
cells[1].text = s["name"]
|
||||||
|
cells[2].text = s["price_str"]
|
||||||
|
cells[3].text = str(s["qty"])
|
||||||
|
cells[4].text = s["sum_str"]
|
||||||
|
cells[5].text = s.get("date_start", pools.random_date(2026, 3, 3))
|
||||||
|
if with_end_date:
|
||||||
|
cells[6].text = s.get("date_end", "")
|
||||||
|
# Итого
|
||||||
|
row = table.add_row()
|
||||||
|
row.cells[0].text = ""
|
||||||
|
row.cells[1].text = f"Итого {label.lower()} в руб с учетом НДС"
|
||||||
|
row.cells[2].text = ""
|
||||||
|
row.cells[3].text = ""
|
||||||
|
row.cells[4].text = total_str
|
||||||
|
row.cells[5].text = "-"
|
||||||
|
if with_end_date:
|
||||||
|
row.cells[6].text = "-"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Билдеры
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_contract(ctx):
|
||||||
|
"""
|
||||||
|
ctx = {
|
||||||
|
company, number, date, company_idx,
|
||||||
|
services[], total, total_str,
|
||||||
|
comment (описание для тестов)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
doc = Document()
|
||||||
|
c = ctx["company"]
|
||||||
|
num = ctx["number"]
|
||||||
|
date = ctx["date"]
|
||||||
|
|
||||||
|
# ── Комментарий для тестов ──
|
||||||
|
_comment_para(doc, (
|
||||||
|
f"Данный файл: ДОГОВОР, компания {c['prefix']}, № {num} от {date}.\n"
|
||||||
|
f"Стороны: {c['legal']} (Заказчик) + {pools.PROVIDER} (Исполнитель).\n"
|
||||||
|
f"Сгенерирован автоматически для тестирования сервиса contracts.\n"
|
||||||
|
f"{ctx.get('comment','')}"
|
||||||
|
))
|
||||||
|
|
||||||
|
_add_title(doc, "Договор на оказание технологических услуг")
|
||||||
|
_add_para(doc, f"№ {num}")
|
||||||
|
_add_para(doc, f'{c["legal"]} именуемое в дальнейшем "Заказчик", в лице {c["dir"]} ФИО, действующего на основании Устава, '
|
||||||
|
f'и {pools.PROVIDER}, именуемое в дальнейшем "Исполнитель", в лице {pools.PROVIDER_DIR}, '
|
||||||
|
f'действующего на основании Устава, заключили настоящий Договор о нижеследующем.')
|
||||||
|
|
||||||
|
# Термины
|
||||||
|
_add_heading(doc, "ТЕРМИНЫ И ОПРЕДЕЛЕНИЯ")
|
||||||
|
terms = [
|
||||||
|
"Услуги – оказываемые Исполнителем Заказчику технологические услуги в области компьютерных и телекоммуникационных технологий.",
|
||||||
|
"Абонентские услуги – Услуги, указанные в Спецификации, предоставляемые ежемесячно.",
|
||||||
|
"Инсталляционные услуги – Услуги по установке и настройке оборудования Заказчика.",
|
||||||
|
"Технологическая площадка – специализированное помещение (ЦОД) по адресу: г. Москва, 1-я Стекольная 7с5.",
|
||||||
|
"Отчетный период – календарный месяц.",
|
||||||
|
]
|
||||||
|
for t in terms:
|
||||||
|
_add_para(doc, t)
|
||||||
|
|
||||||
|
# Предмет
|
||||||
|
_add_heading(doc, "ПРЕДМЕТ ДОГОВОРА")
|
||||||
|
_add_para(doc, f"Исполнитель обязуется оказывать Заказчику технологические Услуги, указанные в Спецификации, "
|
||||||
|
f"а Заказчик обязуется принимать и оплачивать Услуги. Место оказания: {pools.CITY}, {pools.ADDRESS}.")
|
||||||
|
|
||||||
|
# Стоимость и оплата
|
||||||
|
_add_heading(doc, "СТОИМОСТЬ УСЛУГ И ПОРЯДОК ОПЛАТЫ")
|
||||||
|
_add_para(doc, f"Стоимость Услуг указана в Спецификации (Приложение №1). "
|
||||||
|
f"Оплата ежемесячная, постоплатой 100%, на основании УПД и счета. "
|
||||||
|
f"Срок оплаты — 10 рабочих дней с даты выставления счета.")
|
||||||
|
|
||||||
|
# Прочие условия (сокращённо)
|
||||||
|
_add_heading(doc, "ОСОБЫЕ УСЛОВИЯ")
|
||||||
|
_add_para(doc, "Исполнитель вправе приостановить оказание Услуг при просрочке платежей более 10 рабочих дней. "
|
||||||
|
"Заказчик обязан соблюдать правила Технологической площадки.")
|
||||||
|
|
||||||
|
_add_heading(doc, "СРОК ДЕЙСТВИЯ")
|
||||||
|
_add_para(doc, "Договор вступает в силу с момента подписания. Начальный период — 12 месяцев. "
|
||||||
|
"Автоматическая пролонгация, если ни одна из Сторон не уведомит о прекращении за 30 дней.")
|
||||||
|
|
||||||
|
# Реквизиты
|
||||||
|
_add_heading(doc, "ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН")
|
||||||
|
_add_para(doc, f"{pools.CITY} {date}г.")
|
||||||
|
_add_parties_table(doc, c, pools.PROVIDER, pools.PROVIDER_DIR, pools.CITY, date)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def build_spec(ctx):
|
||||||
|
"""
|
||||||
|
ctx = {
|
||||||
|
company, number, date, contract_date,
|
||||||
|
services[], total, total_str,
|
||||||
|
version (1 или 2),
|
||||||
|
label (название секции),
|
||||||
|
comment
|
||||||
|
}
|
||||||
|
Для v2 — slight differences в services (цены/объём/состав).
|
||||||
|
"""
|
||||||
|
doc = Document()
|
||||||
|
c = ctx["company"]
|
||||||
|
num = ctx["number"]
|
||||||
|
date = ctx["date"]
|
||||||
|
contract_date = ctx.get("contract_date", date)
|
||||||
|
ver = ctx.get("version", 1)
|
||||||
|
|
||||||
|
_comment_para(doc, (
|
||||||
|
f"Данный файл: СПЕЦИФИКАЦИЯ v{ver}, компания {c['prefix']}, к договору № {num} от {contract_date}.\n"
|
||||||
|
f"Версия {ver}: {'базовая' if ver == 1 else 'ревизия с изменениями'}.\n"
|
||||||
|
f"Содержит: {len(ctx['services'])} строк(и) услуг, итого {ctx['total_str']} руб.\n"
|
||||||
|
f"{ctx.get('comment','')}"
|
||||||
|
))
|
||||||
|
|
||||||
|
_add_title(doc, f"Приложение № 1")
|
||||||
|
_add_para(doc, f"к Договору на оказание технологических услуг № {num} от {contract_date} г.")
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_heading(doc, "Спецификация и стоимость услуг")
|
||||||
|
_add_para(doc, "Состав и стоимость Услуг")
|
||||||
|
|
||||||
|
label = ctx.get("label", f"Абонентские услуги с {date}")
|
||||||
|
_add_para(doc, label)
|
||||||
|
_add_para(doc, f"Общая стоимость Ежемесячных услуг составляет {ctx['total_str']} руб.")
|
||||||
|
|
||||||
|
_add_services_table(doc, ctx["services"], ctx["total_str"], with_end_date=True, label=label)
|
||||||
|
|
||||||
|
# Дата и город
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, f"{pools.CITY} {date}г.")
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def build_addendum(ctx):
|
||||||
|
"""
|
||||||
|
ctx = {
|
||||||
|
company, number, date, contract_date, addendum_num,
|
||||||
|
services_install[], install_total, install_total_str,
|
||||||
|
services_monthly[], monthly_total, monthly_total_str,
|
||||||
|
comment
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
doc = Document()
|
||||||
|
c = ctx["company"]
|
||||||
|
num = ctx["number"]
|
||||||
|
date = ctx["date"]
|
||||||
|
contract_date = ctx.get("contract_date", date)
|
||||||
|
anum = ctx.get("addendum_num", 1)
|
||||||
|
|
||||||
|
_comment_para(doc, (
|
||||||
|
f"Данный файл: ДОПОЛНИТЕЛЬНОЕ СОГЛАШЕНИЕ №{anum}, компания {c['prefix']}, к договору № {num} от {contract_date}.\n"
|
||||||
|
f"Содержит: инсталляционные услуги ({len(ctx.get('services_install',[]))} стр.), "
|
||||||
|
f"абонентские услуги ({len(ctx.get('services_monthly',[]))} стр.).\n"
|
||||||
|
f"{ctx.get('comment','')}"
|
||||||
|
))
|
||||||
|
|
||||||
|
_add_title(doc, f"Дополнительное Соглашение №{anum}")
|
||||||
|
_add_para(doc, f"к Договору № {num} от {contract_date}")
|
||||||
|
_add_para(doc, f"{pools.CITY} «{date.split('.')[0]}» {_month_name(int(date.split('.')[1]))} {date.split('.')[2]}")
|
||||||
|
_add_para(doc, f'{c["legal"]} именуемое в дальнейшем "Заказчик", в лице {c["dir"]} ФИО, '
|
||||||
|
f'и {pools.PROVIDER}, именуемое в дальнейшем "Исполнитель", в лице {pools.PROVIDER_DIR}, '
|
||||||
|
f'заключили настоящее Соглашение о нижеследующем:')
|
||||||
|
|
||||||
|
_add_heading(doc, "1. ПРЕДМЕТ ДОПОЛНИТЕЛЬНОГО СОГЛАШЕНИЯ")
|
||||||
|
_add_para(doc, "Изменить Спецификацию и стоимость Услуг. Утвердить новую Спецификацию в соответствии с Приложением №1 к настоящему Соглашению.")
|
||||||
|
|
||||||
|
if ctx.get("services_install"):
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, f"Общая стоимость Инсталляционных услуг составляет {ctx['install_total_str']} руб.")
|
||||||
|
_add_services_table(doc, ctx["services_install"], ctx["install_total_str"], with_end_date=False, label="Разовые услуги")
|
||||||
|
|
||||||
|
if ctx.get("services_monthly"):
|
||||||
|
_add_para(doc, "")
|
||||||
|
_add_para(doc, f"Общая стоимость Ежемесячных услуг составляет {ctx['monthly_total_str']} руб.")
|
||||||
|
_add_services_table(doc, ctx["services_monthly"], ctx["monthly_total_str"], with_end_date=True, label="Абонентские услуги")
|
||||||
|
|
||||||
|
_add_heading(doc, "ПРОЧИЕ УСЛОВИЯ")
|
||||||
|
_add_para(doc, "Во всём остальном, что не урегулировано настоящим Соглашением, действуют положения Договора.")
|
||||||
|
|
||||||
|
_add_heading(doc, "ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН")
|
||||||
|
_add_parties_table(doc, c, pools.PROVIDER, pools.PROVIDER_DIR, pools.CITY, date)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _month_name(m):
|
||||||
|
months = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
|
||||||
|
"июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||||
|
return months[m] if 1 <= m <= 12 else str(m)
|
||||||
Reference in New Issue
Block a user